Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
33 changes: 32 additions & 1 deletion src/installer.tr
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/lockfile.tr
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Lock file -- taupkg.lock
#
# Format:
# [pkg]
# [[pkg]]
# name = "pkgname"
# version = "1.2.3"
# source = "github:user/repo@v1.2.3"
Expand Down Expand Up @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions src/source.tr
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ref> -> GitHub repo URL pinned to a branch/tag
# https://github.com/user/repo@<ref> -> 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"
Expand All @@ -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
Expand All @@ -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("@")
Expand Down
68 changes: 62 additions & 6 deletions src/util.tr
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 11 additions & 11 deletions tests/test_semver.tr
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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
64 changes: 64 additions & 0 deletions tests/test_source.tr
Original file line number Diff line number Diff line change
@@ -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/<ref>
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
Loading
Loading