diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f950b93..5302a3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -132,11 +132,32 @@ jobs: exit 1 fi + # ------------------------------------------------------- + # SHA256 checksum of the binary for this platform. Bundled + # alongside the binary in the main artifact AND uploaded as its + # own artifact so it can be downloaded standalone and pasted + # into the release notes. + # ------------------------------------------------------- + - name: Generate SHA256 checksum + shell: bash + run: | + sha256sum "taupkg${{ matrix.ext }}" > "taupkg-${{ matrix.platform }}.sha256" + cat "taupkg-${{ matrix.platform }}.sha256" + - name: Upload artifact uses: actions/upload-artifact@v4 with: name: taupkg-${{ matrix.platform }} - path: taupkg${{ matrix.ext }} + path: | + taupkg${{ matrix.ext }} + taupkg-${{ matrix.platform }}.sha256 + + - name: Upload SHA256 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: taupkg-${{ matrix.platform }}-sha256 + if-no-files-found: error + path: taupkg-${{ matrix.platform }}.sha256 # ----------------------------------------------------------------- # Release: runs only on v* tag pushes after all builds succeed. @@ -182,6 +203,24 @@ jobs: zip -j release/assets/taupkg-windows-x64.zip release/windows-x64/taupkg.exe zip -j release/assets/taupkg-macos-arm64.zip release/macos-arm64/taupkg + # ------------------------------------------------------- + # Combined SHA256SUMS.txt: checksums of the release zips + # (what users actually download) plus the raw per-platform + # binary checksums produced during the build job. + # ------------------------------------------------------- + - name: Generate combined SHA256SUMS.txt + run: | + cd release/assets + sha256sum *.zip > SHA256SUMS.txt + cd - + echo "" >> release/assets/SHA256SUMS.txt + echo "# Per-platform binary checksums (uncompressed):" >> release/assets/SHA256SUMS.txt + cat release/linux-x64/taupkg-linux-x64.sha256 >> release/assets/SHA256SUMS.txt + cat release/linux-arm64/taupkg-linux-arm64.sha256 >> release/assets/SHA256SUMS.txt + cat release/windows-x64/taupkg-windows-x64.sha256 >> release/assets/SHA256SUMS.txt + cat release/macos-arm64/taupkg-macos-arm64.sha256 >> release/assets/SHA256SUMS.txt + cat release/assets/SHA256SUMS.txt + - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -192,4 +231,5 @@ jobs: release/assets/taupkg-linux-x64.zip \ release/assets/taupkg-linux-arm64.zip \ release/assets/taupkg-windows-x64.zip \ - release/assets/taupkg-macos-arm64.zip + release/assets/taupkg-macos-arm64.zip \ + release/assets/SHA256SUMS.txt diff --git a/src/installer.tr b/src/installer.tr index e2a3493..5241a25 100644 --- a/src/installer.tr +++ b/src/installer.tr @@ -10,11 +10,12 @@ from std.io import File from std.os import OS, Process -from util import path_join, mkdir_p, rm_rf, cp_r, shell_capture, project_pkg_dir +from util import path_join, mkdir_p, rm_rf, rm_f, cp_r, flatten_single_subdir, shell_capture, project_pkg_dir from source import Source, parse_source, source_clone_cmd from manifest import Manifest from lockfile import LockedPkg from semver import Version +from crypto.hash import Hash # Install a single package into the project's packages directory. # Returns a LockedPkg describing what was installed, or an empty LockedPkg on failure. @@ -60,6 +61,36 @@ pub def install_package( if installed_version.len() == 0: installed_version = src.ref + elif src.kind == "archive": + mkdir_p(pkg_dir) + mut tmp_file = path_join(project_pkg_dir(project_root), "_" + pkg_name + "_download") + if src.uri.ends_with(".zip"): + tmp_file = tmp_file + ".zip" + elif src.uri.ends_with(".tgz"): + tmp_file = tmp_file + ".tgz" + else: + tmp_file = tmp_file + ".tar.gz" + if verbose: + print(" [archive] downloading " + src.uri) + mut dl_cmd = "curl -fsSL -o \"" + tmp_file + "\" \"" + src.uri + "\"" + mut rc = Process.system(dl_cmd) + if rc != 0: + print("error: failed to download " + src.uri) + return LockedPkg.init("", "", "", "") + if verbose: + print(" [archive] extracting -> " + pkg_dir) + mut ex_cmd = "tar -xf \"" + tmp_file + "\" -C \"" + pkg_dir + "\"" + rc = Process.system(ex_cmd) + rm_f(tmp_file) + if rc != 0: + print("error: failed to extract " + src.uri) + return LockedPkg.init("", "", "", "") + flatten_single_subdir(pkg_dir) + mut mf = Manifest.load(path_join(pkg_dir, "taupkg.toml")) + installed_version = mf.version + if installed_version.len() == 0: + installed_version = src.ref + elif src.kind == "registry": # Registry install: ask registry for available versions, pick best, then clone mut reg_result = registry_resolve(pkg_name, constraint) diff --git a/src/lockfile.tr b/src/lockfile.tr index e0c25fb..2cd5506 100644 --- a/src/lockfile.tr +++ b/src/lockfile.tr @@ -1,7 +1,7 @@ # Lock file -- taupkg.lock # # Format: -# [pkg] +# [[pkg]] # name = "pkgname" # version = "1.2.3" # source = "github:user/repo@v1.2.3" @@ -63,7 +63,7 @@ extend Lockfile: mut i = 0 while i < self.packages.len: mut p = self.packages.get(i) - out = out + "[pkg]\n" + out = out + "[[pkg]]\n" out = out + "name = \"" + p.name + "\"\n" out = out + "version = \"" + p.version + "\"\n" out = out + "source = \"" + p.source + "\"\n" diff --git a/src/source.tr b/src/source.tr index 6798f27..47f48e3 100644 --- a/src/source.tr +++ b/src/source.tr @@ -7,6 +7,14 @@ # local:./relative/path -> local directory (symlinked/copied) # local:/absolute/path -> absolute local directory # registry:name@constraint -> (default) look up in official registry +# +# https://github.com/user/repo -> plain GitHub repo URL (git clone) +# https://github.com/user/repo.git -> same, with explicit .git +# https://github.com/user/repo/tree/ -> GitHub repo URL pinned to a branch/tag +# https://github.com/user/repo@ -> same, '@' form +# https://.../archive/refs/tags/v1.zip -> GitHub source archive (download + extract) +# https://.../releases/download/v1/x.tar.gz -> GitHub release asset (download + extract) +# archive:https://... -> explicit archive download (round-trip form) pub class Source: pub kind: str # "github" | "git" | "local" | "registry" @@ -22,6 +30,8 @@ extend Source: return s pub def to_string(self) -> str: + if self.kind == "archive": + return "archive:" + self.uri if self.ref.len() > 0: return self.kind + ":" + self.uri + "@" + self.ref return self.kind + ":" + self.uri @@ -32,6 +42,37 @@ extend Source: pub def parse_source(pkg_name: str, raw: str) -> Source: mut s = raw.trim() + if s.starts_with("archive:"): + return Source.init("archive", s.slice(8, s.len()), "") + + # Bare GitHub/HTTP URLs (not wrapped in github:/git: prefixes). + if s.starts_with("https://") or s.starts_with("http://"): + # GitHub source archives and release assets are downloaded and + # extracted directly, not git-cloned. + if s.contains("/releases/download/") or s.contains("/archive/") or s.ends_with(".tar.gz") or s.ends_with(".tgz") or s.ends_with(".zip"): + return Source.init("archive", s, "") + + if s.contains("github.com/"): + mut rest = s.slice(s.index_of("github.com/") + 11, s.len()) + mut ref = "" + mut tree_pos = rest.index_of("/tree/") + if tree_pos >= 0: + ref = rest.slice(tree_pos + 6, rest.len()) + rest = rest.slice(0, tree_pos) + else: + mut at_pos = rest.index_of("@") + if at_pos >= 0: + ref = rest.slice(at_pos + 1, rest.len()) + rest = rest.slice(0, at_pos) + while rest.len() > 0 and rest.ends_with("/"): + rest = rest.slice(0, rest.len() - 1) + if rest.ends_with(".git"): + rest = rest.slice(0, rest.len() - 4) + return Source.init("github", "https://github.com/" + rest + ".git", ref) + + # Arbitrary git-hosting URL (not GitHub). + return Source.init("git", s, "") + if s.starts_with("github:"): mut rest = s.slice(7, s.len()) mut at_pos = rest.index_of("@") diff --git a/src/util.tr b/src/util.tr index e97cca4..e52afe0 100644 --- a/src/util.tr +++ b/src/util.tr @@ -1,6 +1,6 @@ # Filesystem and OS utilities for taupkg. -from std.io import File +from std.io import File, Dir from std.os import OS, Process, Env # Join two path segments with the platform separator. @@ -74,16 +74,72 @@ pub def rm_rf(dir: str): Process.system(cmd) +# Remove a single file. +pub def rm_f(path: str): + if OS.is_windows(): + mut cmd = "del /q \"" + path + "\" 2>nul" + Process.system(cmd) + else: + mut cmd = "rm -f \"" + path + "\"" + Process.system(cmd) + + +# Move/rename a file or directory. +pub def mv(src: str, dest: str) -> bool: + if OS.is_windows(): + mut cmd = "move /y \"" + src + "\" \"" + dest + "\" >nul 2>&1" + return Process.system(cmd) == 0 + else: + mut cmd = "mv \"" + src + "\" \"" + dest + "\"" + return Process.system(cmd) == 0 + + +# GitHub source archives and release tarballs extract into a single +# top-level directory (e.g. "repo-1.2.3/"). If `dir` contains exactly one +# entry and that entry is a directory, hoist its contents up into `dir` +# and remove the now-empty wrapper. +pub def flatten_single_subdir(dir: str): + mut entries = Dir.init(dir).list() + if entries.len != 1: + return + mut only = path_join(dir, entries.get(0)) + if not File.dir_exists(only): + return + mut tmp = dir + "__flatten_tmp" + if not mv(only, tmp): + return + mut inner = Dir.init(tmp).list() + mut i = 0 + while i < inner.len: + mv(path_join(tmp, inner.get(i)), path_join(dir, inner.get(i))) + i = i + 1 + rm_rf(tmp) + + # Copy a directory tree recursively. +# +# Excludes .taupkg/, .git/, build/ and target/ from the copy. This matters for +# local: deps whose project root contains its own example/app nested inside it +# (e.g. watax/example/app) -- without the exclusion, copying watax/ into +# watax/example/app/.taupkg/packages/watax/ is a cyclic copy (dest is inside +# src) and xcopy/cp both fail. pub def cp_r(src: str, dest: str) -> bool: if OS.is_windows(): - mut cmd = "xcopy /e /i /q \"" + src + "\" \"" + dest + "\" >nul 2>&1" + # robocopy exit codes 0-7 are success (various "files copied" flags); + # 8+ indicates failure. /XD excludes directories anywhere in the tree. + mut cmd = "robocopy \"" + src + "\" \"" + dest + "\" /e /XD .taupkg .git build target /NFL /NDL /NJH /NJS /NC /NS /NP >nul 2>&1" mut rc = Process.system(cmd) - return rc == 0 + return rc < 8 else: - mut cmd = "cp -r \"" + src + "\" \"" + dest + "\"" - mut rc = Process.system(cmd) - return rc == 0 + if cmd_exists("rsync"): + mkdir_p(dest) + mut cmd = "rsync -a --exclude='.taupkg' --exclude='.git' --exclude='build' --exclude='target' \"" + src + "/\" \"" + dest + "/\"" + mut rc = Process.system(cmd) + return rc == 0 + else: + mut cmd = "cp -r \"" + src + "\" \"" + dest + "\"" + mut rc = Process.system(cmd) + return rc == 0 # Run a shell command and return its trimmed stdout output. diff --git a/tests/test_semver.tr b/tests/test_semver.tr index 00b9849..636bf33 100644 --- a/tests/test_semver.tr +++ b/tests/test_semver.tr @@ -1,6 +1,6 @@ from src.semver import Version -def assert_eq(got: str, want: str, label: str): +def check_eq(got: str, want: str, label: str): if got != want: print("FAIL " + label + ": got=" + got + " want=" + want) else: @@ -19,16 +19,16 @@ def assert_bool(got: bool, want: bool, label: str): def main() -> int: # Parse mut v = Version.init("1.2.3") - assert_eq(str(v.major), "1", "parse major") - assert_eq(str(v.minor), "2", "parse minor") - assert_eq(str(v.patch), "3", "parse patch") + check_eq(str(v.major), "1", "parse major") + check_eq(str(v.minor), "2", "parse minor") + check_eq(str(v.patch), "3", "parse patch") mut vv = Version.init("v0.10.0") - assert_eq(str(vv.major), "0", "parse v-prefix major") - assert_eq(str(vv.minor), "10", "parse v-prefix minor") + check_eq(str(vv.major), "0", "parse v-prefix major") + check_eq(str(vv.minor), "10", "parse v-prefix minor") # to_string - assert_eq(v.to_string(), "1.2.3", "to_string") + check_eq(v.to_string(), "1.2.3", "to_string") # compare mut a = Version.init("1.0.0") @@ -81,10 +81,10 @@ def main() -> int: versions.push("1.1.0") versions.push("1.2.0") versions.push("2.0.0") - assert_eq(Version.best_match(versions, "^1.0.0"), "1.2.0", "best_match caret") - assert_eq(Version.best_match(versions, "~1.1.0"), "1.1.0", "best_match tilde") - assert_eq(Version.best_match(versions, ">=2.0.0"), "2.0.0", "best_match gte exact") - assert_eq(Version.best_match(versions, ">2.0.0"), "", "best_match none satisfies") + check_eq(Version.best_match(versions, "^1.0.0"), "1.2.0", "best_match caret") + check_eq(Version.best_match(versions, "~1.1.0"), "1.1.0", "best_match tilde") + check_eq(Version.best_match(versions, ">=2.0.0"), "2.0.0", "best_match gte exact") + check_eq(Version.best_match(versions, ">2.0.0"), "", "best_match none satisfies") print("semver tests done") return 0 diff --git a/tests/test_source.tr b/tests/test_source.tr new file mode 100644 index 0000000..82a50da --- /dev/null +++ b/tests/test_source.tr @@ -0,0 +1,64 @@ +from src.source import parse_source + +def check_eq(got: str, want: str, label: str): + if got != want: + print("FAIL " + label + ": got=" + got + " want=" + want) + else: + print("ok " + label) + +def main() -> int: + # github: shorthand (existing forms) + mut a = parse_source("foo", "github:user/repo") + check_eq(a.kind, "github", "github: kind") + check_eq(a.uri, "https://github.com/user/repo.git", "github: uri") + check_eq(a.ref, "", "github: ref") + + mut b = parse_source("foo", "github:user/repo@v1.2.3") + check_eq(b.ref, "v1.2.3", "github: ref pinned") + + # bare https://github.com/user/repo + mut c = parse_source("foo", "https://github.com/Yusee-Programmer/templa") + check_eq(c.kind, "github", "bare url kind") + check_eq(c.uri, "https://github.com/Yusee-Programmer/templa.git", "bare url uri") + check_eq(c.ref, "", "bare url ref") + + # bare https with .git suffix and trailing slash + mut d = parse_source("foo", "https://github.com/Yusee-Programmer/templa.git/") + check_eq(d.uri, "https://github.com/Yusee-Programmer/templa.git", "bare url .git/ uri") + + # bare https with /tree/ + mut e = parse_source("foo", "https://github.com/Yusee-Programmer/templa/tree/main") + check_eq(e.uri, "https://github.com/Yusee-Programmer/templa.git", "tree-ref uri") + check_eq(e.ref, "main", "tree-ref ref") + + # bare https with @ref + mut f = parse_source("foo", "https://github.com/Yusee-Programmer/templa@v0.1.0") + check_eq(f.uri, "https://github.com/Yusee-Programmer/templa.git", "@ref uri") + check_eq(f.ref, "v0.1.0", "@ref ref") + + # GitHub source archive (zip) + mut g = parse_source("foo", "https://github.com/user/repo/archive/refs/tags/v1.0.0.zip") + check_eq(g.kind, "archive", "archive kind (source zip)") + check_eq(g.uri, "https://github.com/user/repo/archive/refs/tags/v1.0.0.zip", "archive uri (source zip)") + + # GitHub release asset (tar.gz) + mut h = parse_source("foo", "https://github.com/user/repo/releases/download/v1.0.0/asset.tar.gz") + check_eq(h.kind, "archive", "archive kind (release asset)") + + # archive: round-trip form + mut i_src = parse_source("foo", "archive:https://example.com/pkg.tar.gz") + check_eq(i_src.kind, "archive", "archive: prefix kind") + check_eq(i_src.uri, "https://example.com/pkg.tar.gz", "archive: prefix uri") + check_eq(i_src.to_string(), "archive:https://example.com/pkg.tar.gz", "archive to_string round-trip") + + # local: still works + mut j = parse_source("foo", "local:./vendor/foo") + check_eq(j.kind, "local", "local kind") + check_eq(j.uri, "./vendor/foo", "local uri") + + # plain version constraint -> registry + mut k = parse_source("foo", "^1.2.0") + check_eq(k.kind, "registry", "registry kind") + check_eq(k.ref, "^1.2.0", "registry constraint") + + return 0 diff --git a/tests/test_toml.tr b/tests/test_toml.tr index e81bddc..6945c7e 100644 --- a/tests/test_toml.tr +++ b/tests/test_toml.tr @@ -1,6 +1,6 @@ from src.toml import toml_parse, TomlDoc -def assert_eq(got: str, want: str, label: str): +def check_eq(got: str, want: str, label: str): if got != want: print("FAIL " + label + ": got='" + got + "' want='" + want + "'") else: @@ -28,30 +28,30 @@ email = "bob@example.com" mut doc = toml_parse(content) - assert_eq(doc.get("package", "name"), "mylib", "package.name") - assert_eq(doc.get("package", "version"), "0.2.0", "package.version") - assert_eq(doc.get("package", "license"), "Apache-2.0", "package.license inline comment stripped") + check_eq(doc.get("package", "name"), "mylib", "package.name") + check_eq(doc.get("package", "version"), "0.2.0", "package.version") + check_eq(doc.get("package", "license"), "Apache-2.0", "package.license inline comment stripped") - assert_eq(doc.get("deps", "http"), "^1.0.0", "deps.http") - assert_eq(doc.get("deps", "json"), "~2.3.0", "deps.json") + check_eq(doc.get("deps", "http"), "^1.0.0", "deps.http") + check_eq(doc.get("deps", "json"), "~2.3.0", "deps.json") mut authors = doc.get_array("author") - assert_eq(str(authors.len), "2", "array section count") + check_eq(str(authors.len), "2", "array section count") mut a0 = authors.get(0) - assert_eq(a0.keys.get("name"), "Alice", "author[0].name") - assert_eq(a0.keys.get("email"), "alice@example.com", "author[0].email") + check_eq(a0.keys.get("name"), "Alice", "author[0].name") + check_eq(a0.keys.get("email"), "alice@example.com", "author[0].email") mut a1 = authors.get(1) - assert_eq(a1.keys.get("name"), "Bob", "author[1].name") - assert_eq(a1.keys.get("email"), "bob@example.com", "author[1].email") + check_eq(a1.keys.get("name"), "Bob", "author[1].name") + check_eq(a1.keys.get("email"), "bob@example.com", "author[1].email") # Missing key returns "" - assert_eq(doc.get("package", "nonexistent"), "", "missing key returns empty") + check_eq(doc.get("package", "nonexistent"), "", "missing key returns empty") # Missing section returns empty section (no crash) mut ghost = doc.get_section("nosuchsection") - assert_eq(ghost.name, "nosuchsection", "missing section returns placeholder") + check_eq(ghost.name, "nosuchsection", "missing section returns placeholder") print("toml tests done") return 0