diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 877a2d0..d8fbfaf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,8 +85,6 @@ jobs: visibility: public runner-map: | { - "aarch64-darwin": "namespace-profile-mac-m2-12c28g", - "aarch64-linux": "UbuntuLatest32Cores128GArm", "x86_64-linux": "UbuntuLatest32Cores128G" } @@ -147,13 +145,13 @@ jobs: nix build -j0 .#packages.x86_64-linux.default cp -r ./result/bin/flakehub-push ./artifacts/X64-Linux - nix build -j0 .#packages.aarch64-linux.default - cp -r ./result/bin/flakehub-push ./artifacts/ARM64-Linux + # nix build -j0 .#packages.aarch64-linux.default + # cp -r ./result/bin/flakehub-push ./artifacts/ARM64-Linux - nix build -j0 .#packages.aarch64-darwin.default - cp -r ./result/bin/flakehub-push ./artifacts/ARM64-macOS + # nix build -j0 .#packages.aarch64-darwin.default + # cp -r ./result/bin/flakehub-push ./artifacts/ARM64-macOS - - uses: DeterminateSystems/push-artifact-ids@main + - uses: DeterminateSystems/push-artifact-ids@wip with: s3_upload_role: ${{ secrets.AWS_S3_UPLOAD_ROLE }} bucket: ${{ secrets.AWS_S3_UPLOAD_BUCKET }} diff --git a/action.yaml b/action.yaml index d1d17aa..aea38b4 100644 --- a/action.yaml +++ b/action.yaml @@ -113,6 +113,12 @@ inputs: source-url: description: A URL pointing to a `flakehub-push` binary. Overrides all other `source-*` options. required: false + source-checksums-url: + description: wip + required: false + source-checksums-sha256: + description: wip + required: false _internal-strict-mode: description: Whether to fail when any errors are thrown. Used only to test the Action; do not set this in your own workflows. default: false diff --git a/dist/index.js b/dist/index.js index fd63956..31590ef 100644 --- a/dist/index.js +++ b/dist/index.js @@ -107318,6 +107318,52 @@ async function collectBacktracesSystemd(prefixes, programNameDenyList, startTime return backtraces; } //#endregion +//#region src/checksums.ts +/** +* @packageDocumentation +* Parsing and hashing helpers for `shasum`-format checksum files, used to +* hash-lock downloaded artifacts. +*/ +const HEX_STRING_RE = /^[0-9a-fA-F]+$/; +/** +* Parse a `shasum`-format checksums file into a map of filename -> hex digest. +* +* Each non-empty line has the shape ``. Lines +* without a space delimiter are skipped. Invalid hex digests throw, so a +* malformed file fails loudly rather than silently skipping the entry we +* care about. +*/ +function parseChecksumsFile(text) { + const result = /* @__PURE__ */ new Map(); + for (const record of text.split(/\r\n|\n|\r/).filter(Boolean)) { + const delimIndex = record.indexOf(" "); + if (delimIndex === -1) continue; + const digest = record.slice(0, delimIndex); + if (!HEX_STRING_RE.test(digest)) throw new Error(`Invalid digest in checksums file: ${digest}`); + const name = record.slice(delimIndex + 1).trim(); + if (name === "") continue; + result.set(name, digest.toLowerCase()); + } + return result; +} +/** +* Compute the SHA-256 of a file on disk and return its lowercase hex digest. +* Streams the file so memory use is constant regardless of size. +*/ +async function sha256OfFile(filePath) { + return new Promise((resolve, reject) => { + const hash = (0,external_node_crypto_.createHash)("sha256").setEncoding("hex"); + (0,external_node_fs_namespaceObject.createReadStream)(filePath).once("error", reject).pipe(hash).once("finish", () => resolve(hash.read())); + }); +} +/** +* Compute the SHA-256 of an in-memory buffer or string and return its +* lowercase hex digest. +*/ +function sha256OfBuffer(data) { + return (0,external_node_crypto_.createHash)("sha256").update(data).digest("hex"); +} +//#endregion //#region src/correlation.ts const OPTIONAL_VARIABLES = ["INVOCATION_ID"]; function identify() { @@ -107708,6 +107754,16 @@ function getNixPlatform(archOs) { } //#endregion //#region src/sourcedef.ts +/** +* Throw if hash-locking is requested against a source that is not pinned to a +* fixed version. `source-tag`, `source-revision`, and `source-url` are +* immutable (or caller-controlled); any other selector resolves to a moving +* target (`branch`, `pr`, or the `stable` fallback) where the pinned checksum +* would break the moment a new release is published. +*/ +function assertChecksumSourceIsPinned(source) { + if (source.url === void 0 && source.tag === void 0 && source.revision === void 0) throw new Error("Hash-locking via `source-checksums-url`/`source-checksums-sha256` requires a pinned source: set `source-tag`, `source-revision`, or `source-url`. Without one the action resolves to a moving target (e.g. `stable`) and the checksum will break the next time a release is published."); +} function constructSourceParameters(legacyPrefix) { return { path: noisilyGetInput("path", legacyPrefix), @@ -107751,6 +107807,7 @@ const FACT_OS = "$os"; const FACT_OS_VERSION = "$os_version"; const FACT_SOURCE_URL = "source_url"; const FACT_SOURCE_URL_ETAG = "source_url_etag"; +const FACT_SOURCE_CHECKSUMS_SHA256 = "source_checksums_sha256"; const FACT_NIX_VERSION = "nix_version"; const FACT_NIX_LOCATION = "nix_location"; const FACT_NIX_STORE_TRUST = "nix_store_trusted"; @@ -108091,6 +108148,11 @@ var DetSysAction = class { * to a binary on disk; otherwise, the artifact will be downloaded from the * URL determined by the other `source-*` inputs (`source-url`, `source-pr`, * etc.). + * + * When `source-checksums-url` and `source-checksums-sha256` are both set, + * the downloaded artifact is verified against the per-arch hash in the + * checksums file, which is itself verified against the pinned + * `source-checksums-sha256`. Both inputs must be set together. */ async fetchArtifact() { const sourceBinary = getStringOrNull("source-binary"); @@ -108098,6 +108160,7 @@ var DetSysAction = class { core_debug(`Using the provided source binary at ${sourceBinary}`); return sourceBinary; } + const expectedArtifactHash = await this.resolveExpectedArtifactHash(); startGroup(`Downloading ${this.actionOptions.name} for ${this.architectureFetchSuffix}`); try { info(`Fetching from ${await this.getSourceUrl()}`); @@ -108109,10 +108172,11 @@ var DetSysAction = class { const v = versionCheckup.headers.etag; this.addFact(FACT_SOURCE_URL_ETAG, v); core_debug(`Checking the tool cache for ${await this.getSourceUrl()} at ${v}`); - const cached = await this.getCachedVersion(v); + const cached = await this.getCachedVersion(v, expectedArtifactHash); if (cached) { this.facts[FACT_ARTIFACT_FETCHED_FROM_CACHE] = true; core_debug(`Tool cache hit.`); + await this.verifyArtifactHash(cached, expectedArtifactHash); return cached; } } @@ -108120,10 +108184,11 @@ var DetSysAction = class { core_debug(`No match from the cache, re-fetching from the redirect: ${versionCheckup.url}`); const destFile = this.getTemporaryName(); const fetchStream = await this.downloadFile(new URL(versionCheckup.url), destFile); + await this.verifyArtifactHash(destFile, expectedArtifactHash); if (fetchStream.response?.headers.etag) { const v = fetchStream.response.headers.etag; try { - await this.saveCachedVersion(v, destFile); + await this.saveCachedVersion(v, destFile, expectedArtifactHash); } catch (e) { core_debug(`Error caching the artifact: ${stringifyError$1(e)}`); } @@ -108137,6 +108202,41 @@ var DetSysAction = class { } } /** + * Read the `source-checksums-url` and `source-checksums-sha256` inputs and, + * if both are set, fetch the checksums file, verify its hash matches the + * pin, parse it, and return the expected hash for the artifact matching + * this runner's `${name}-${architectureFetchSuffix}`. Returns `null` when + * verification is opted out (both inputs unset). + */ + async resolveExpectedArtifactHash() { + const checksumsUrl = getStringOrNull("source-checksums-url"); + const checksumsSha256 = getStringOrNull("source-checksums-sha256"); + if (checksumsUrl === null && checksumsSha256 === null) return null; + if (checksumsUrl === null || checksumsSha256 === null) throw new Error("`source-checksums-url` and `source-checksums-sha256` must be set together"); + assertChecksumSourceIsPinned(this.sourceParameters); + const expectedFileHash = checksumsSha256.toLowerCase(); + this.addFact(FACT_SOURCE_CHECKSUMS_SHA256, expectedFileHash); + const parsedUrl = new URL(checksumsUrl); + const safeUrl = parsedUrl.origin + parsedUrl.pathname; + info(`Fetching checksums file from ${safeUrl}`); + const body = (await (await this.getClient()).get(checksumsUrl)).body; + const actualFileHash = sha256OfBuffer(body); + if (actualFileHash !== expectedFileHash) throw new Error(`Checksums file hash mismatch at ${safeUrl}: expected ${expectedFileHash}, got ${actualFileHash}`); + const wanted = `${this.actionOptions.name}-${this.architectureFetchSuffix}`; + const artifactHash = parseChecksumsFile(body).get(wanted); + if (artifactHash === void 0) throw new Error(`No entry for ${wanted} in checksums file at ${safeUrl}`); + return artifactHash; + } + /** + * Verify a downloaded artifact's SHA-256 matches the expected hash. No-op + * when `expected` is `null` (verification disabled). + */ + async verifyArtifactHash(filePath, expected) { + if (expected === null) return; + const actual = await sha256OfFile(filePath); + if (actual !== expected) throw new Error(`Artifact hash mismatch for ${this.architectureFetchSuffix}: expected ${expected}, got ${actual}`); + } + /** * A helper function for failing on error only if strict mode is enabled. * This is intended only for CI environments testing Actions themselves. */ @@ -108196,11 +108296,12 @@ var DetSysAction = class { this.addFact(FACT_SOURCE_URL, fetchUrl.toString()); return fetchUrl; } - cacheKey(version) { + cacheKey(version, expectedHash) { const cleanedVersion = version.replace(/[^a-zA-Z0-9-+.]/g, ""); - return `determinatesystem-${this.actionOptions.name}-${this.architectureFetchSuffix}-${cleanedVersion}`; + const hashSuffix = expectedHash ? `-h${expectedHash}` : ""; + return `determinatesystem-${this.actionOptions.name}-${this.architectureFetchSuffix}-${cleanedVersion}${hashSuffix}`; } - async getCachedVersion(version) { + async getCachedVersion(version, expectedHash) { const startCwd = process.cwd(); try { const tempDir = this.getTemporaryName(); @@ -108208,7 +108309,7 @@ var DetSysAction = class { process.chdir(tempDir); process.env.GITHUB_WORKSPACE_BACKUP = process.env.GITHUB_WORKSPACE; delete process.env.GITHUB_WORKSPACE; - if (await restoreCache([this.actionOptions.name], this.cacheKey(version), [], void 0, true)) { + if (await restoreCache([this.actionOptions.name], this.cacheKey(version, expectedHash), [], void 0, true)) { this.recordEvent(EVENT_ARTIFACT_CACHE_HIT); return `${tempDir}/${this.actionOptions.name}`; } @@ -108220,7 +108321,7 @@ var DetSysAction = class { process.chdir(startCwd); } } - async saveCachedVersion(version, toolPath) { + async saveCachedVersion(version, toolPath, expectedHash) { const startCwd = process.cwd(); try { const tempDir = this.getTemporaryName(); @@ -108229,7 +108330,7 @@ var DetSysAction = class { await (0,promises_namespaceObject.copyFile)(toolPath, `${tempDir}/${this.actionOptions.name}`); process.env.GITHUB_WORKSPACE_BACKUP = process.env.GITHUB_WORKSPACE; delete process.env.GITHUB_WORKSPACE; - await cache_saveCache([this.actionOptions.name], this.cacheKey(version), void 0, true); + await cache_saveCache([this.actionOptions.name], this.cacheKey(version, expectedHash), void 0, true); this.recordEvent(EVENT_ARTIFACT_CACHE_PERSIST); } finally { process.env.GITHUB_WORKSPACE = process.env.GITHUB_WORKSPACE_BACKUP; diff --git a/flake.nix b/flake.nix index 0d45f9b..b2479ce 100644 --- a/flake.nix +++ b/flake.nix @@ -65,31 +65,29 @@ packages = forAllSystems ( { system, pkgs, ... }: let + lib = pkgs.lib; + sharedAttrs = { pname = "flakehub-push"; version = "0.1.0"; - src = pkgs.craneLib.path ( - builtins.path { - name = "flakehub-push-source"; - path = inputs.self; - filter = ( - path: type: - baseNameOf path != "ts" - && baseNameOf path != "dist" - && baseNameOf path != ".github" - && path != "flake.nix" - ); - } - ); + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./.cargo + ./src + ]; + }; - buildInputs = pkgs.lib.optionals (pkgs.stdenv.isDarwin) ( + buildInputs = lib.optionals (pkgs.stdenv.isDarwin) ( with pkgs; [ libiconv ] ); } - // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { + // lib.optionalAttrs pkgs.stdenv.isLinux { CARGO_BUILD_TARGET = { "x86_64-linux" = "x86_64-unknown-linux-musl"; @@ -106,7 +104,7 @@ sharedAttrs // { cargoArtifacts = pkgs.craneLib.buildDepsOnly sharedAttrs; - postFixup = pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + postFixup = lib.optionalString pkgs.stdenv.isDarwin '' install_name_tool -change \ "$(otool -L $out/bin/flakehub-push | grep libiconv | awk '{print $1}')" \ /usr/lib/libiconv.2.dylib \ @@ -133,6 +131,7 @@ cargo nodejs_latest + typescript-language-server bacon self.formatter.${system} diff --git a/package.json b/package.json index f4e1ec1..6953782 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@actions/core": "^3.0.0", "@actions/exec": "^3.0.0", "@actions/github": "^9.0.0", - "detsys-ts": "github:DeterminateSystems/detsys-ts" + "detsys-ts": "github:DeterminateSystems/detsys-ts#ddf1678f505b22515b9cabfb79f38ce663a876a1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3",