From a868dba40fe1d42dba62dd21bd8358aed1a57eb7 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 15 Aug 2026 12:18:24 -0600 Subject: [PATCH 1/2] feat: add root-level action.yml GitHub Action for installing fledge in CI CorvidLabs/fledge had no action.yml on any branch, so `uses: CorvidLabs/fledge@v1.7.2` failed with "Can't find 'action.yml'". Consumers fell back to curl-piping install.sh, whose latest-version lookup hits the unauthenticated, per-IP-rate-limited releases/latest API -- CorvidLabs/rune lost four CI runs to "could not determine latest version" from this. Adds: - action.yml: composite action. A concrete `version` (e.g. v1.7.2) downloads the release asset directly with zero API calls; `version: latest` resolves via an authenticated API call (github.token by default). Verifies every download against its .sha256 sidecar (warns and skips only for releases that predate sidecars). Fails clearly on Windows/unsupported arch before any network call. Every curl call retries transient network flake. - .github/workflows/test-action.yml: exercises the action on every push, ubuntu-latest and macos-latest, for both a pinned tag and latest (via `uses: ./` so it always tests the branch's own code), plus a dedicated windows-unsupported regression guard. - README.md: new GitHub Actions section, pinned form shown first. - CONTRIBUTING.md: documents moving the v1 tag as a manual post-release step. Also archives CHG-0007, which this change's own change-sequence.json bump staled (the same recurring upstream spec-sync bug fixed for five other records in c4b06f1/#506 -- an accepted, already-merged change stuck unable to reach `archived` because the archive staleness preflight is exactly what the bug breaks). Applied the identical remediation: snapshot state.json to accepted-state.json, move to .specsync/archive/changes/-, flip state to archived. No evidence altered -- approvals.json/verification.json verified byte-identical to git HEAD via SHA-256 before the move. Taken through the full verified SDD change lifecycle (CHG-0008): definition approval, implementation, specsync change verify (fledge lanes run verify-native, green), closing approval, accept. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M1Ts8qwUqfvZK8GtE21bhm --- .github/workflows/test-action.yml | 85 ++++++++++++ .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 0 .../context.md | 0 .../design.md | 0 .../plan.md | 0 .../state.json | 39 ++++++ .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .specsync/change-sequence.json | 6 +- .../approvals.json | 26 ++++ .../change.md | 24 ++++ .../context.md | 62 +++++++++ .../design.md | 89 +++++++++++++ .../docs.md | 41 ++++++ .../plan.md | 32 +++++ .../requirements.md | 53 ++++++++ .../state.json | 40 ++++++ .../tasks.md | 16 +++ .../testing.md | 60 +++++++++ .../verification-attempts.json | 20 +++ .../verification.json | 71 ++++++++++ CONTRIBUTING.md | 16 +++ README.md | 21 +++ action.yml | 125 ++++++++++++++++++ 28 files changed, 823 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test-action.yml rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/accepted-state.json} (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/approvals.json (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/change.md (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/context.md (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/design.md (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/plan.md (100%) create mode 100644 .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/tasks.md (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/testing.md (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants => archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants}/verification.json (100%) create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/change.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/context.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/design.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/docs.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/plan.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/requirements.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/tasks.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/testing.md create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json create mode 100644 .specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json create mode 100644 action.yml diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..5cf5d08 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,85 @@ +name: Test Action + +# Exercises action.yml itself (the root-level composite "Setup Fledge" action), +# not the fledge binary's own test suite (that's ci.yml). Runs on every push +# so a break here is caught immediately — this action exists specifically +# because a silent break in the curl-based install workaround cost +# CorvidLabs/rune several CI runs. See AGENTS.md and action.yml. + +permissions: + contents: read + +on: + push: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: test-action-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + install: + name: install (${{ matrix.os }}, ${{ matrix.version }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + version: [v1.7.2, latest] + steps: + - uses: actions/checkout@v5 + + - id: setup + uses: ./ + with: + version: ${{ matrix.version }} + + - name: Verify outputs + shell: bash + run: | + set -euo pipefail + + if [ -z "${{ steps.setup.outputs.version }}" ]; then + echo "::error::outputs.version is empty" >&2 + exit 1 + fi + if [ -z "${{ steps.setup.outputs.path }}" ]; then + echo "::error::outputs.path is empty" >&2 + exit 1 + fi + + # A pinned version must round-trip unchanged; "latest" must resolve + # to a real vX.Y.Z tag rather than passing the literal through. + if [ "${{ matrix.version }}" != "latest" ] && [ "${{ steps.setup.outputs.version }}" != "${{ matrix.version }}" ]; then + echo "::error::pinned version ${{ matrix.version }} but outputs.version was ${{ steps.setup.outputs.version }}" >&2 + exit 1 + fi + + "${{ steps.setup.outputs.path }}" --version + command -v fledge + + windows-unsupported: + name: windows fails with a readable message + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + + - id: setup + uses: ./ + continue-on-error: true + with: + version: v1.7.2 + + - name: Confirm it failed for the right reason + shell: bash + run: | + set -euo pipefail + if [ "${{ steps.setup.outcome }}" != "failure" ]; then + echo "::error::expected the action to fail on windows-latest, but it succeeded. If Windows support was added, update this job and README.md." >&2 + exit 1 + fi + echo "Confirmed: the action fails cleanly on Windows, as expected." diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/accepted-state.json diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/approvals.json b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/approvals.json similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/approvals.json rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/approvals.json diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/change.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/change.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/change.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/change.md diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/context.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/context.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/context.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/context.md diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/design.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/design.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/design.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/design.md diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/plan.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/plan.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/plan.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/plan.md diff --git a/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json new file mode 100644 index 0000000..ca0fce0 --- /dev/null +++ b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/state.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "id": "CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants", + "slug": "name-the-two-remaining-json-schema-version-literals-as-per-command-constants", + "title": "Name the two remaining --json schema_version literals as per-command constants", + "description": "Name the two remaining --json schema_version literals as per-command constants", + "kind": "refactor", + "state": "archived", + "canonical_applied": true, + "base_commit": "df9ea9c5fd2b6d553a4334afecfbd04cbe23281b", + "created_at": 1785888531, + "updated_at": 1786816918, + "affected_specs": [], + "affected_paths": [ + "src/envelope.rs", + "src/lanes/mod.rs", + "src/lanes/validate.rs", + "src/plugin/mod.rs", + "src/plugin/validate.rs", + ".specsync/change-sequence.json" + ], + "no_spec_change": true, + "no_spec_change_rationale": "Introduces named per-command schema constants and envelope tests only; emitted JSON bytes are unchanged, so no canonical spec contract moves.", + "acceptance_criteria": [ + "cargo test, cargo clippy --all-targets -- -D warnings and cargo fmt --check are green; fledge plugins validate --json and fledge lanes validate --json emit byte-identical output to before the change; PLUGINS_VALIDATE_SCHEMA and LANES_VALIDATE_SCHEMA are defined beside the existing per-command constants and used at their call sites; envelope tests cover resource byte-identity and versioned struct flattening." + ], + "selected_artifacts": [ + "context", + "plan", + "testing", + "design", + "tasks" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "no" + } +} \ No newline at end of file diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/tasks.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/tasks.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/tasks.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/tasks.md diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/testing.md b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/testing.md similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/testing.md rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/testing.md diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification-attempts.json b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification-attempts.json rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification-attempts.json diff --git a/.specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification.json b/.specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification.json similarity index 100% rename from .specsync/changes/CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification.json rename to .specsync/archive/changes/2026-08-07-CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants/verification.json diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index d947a96..b582799 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 7, - "id": "CHG-0007-name-the-two-remaining-json-schema-version-literals-as-per-command-constants", + "sequence": 8, + "id": "CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci", "acknowledged_collisions": [] -} \ No newline at end of file +} diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json new file mode 100644 index 0000000..9d79d86 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json @@ -0,0 +1,26 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1786816279, + "digest": "f0652c769312ce8a726bb093368dfb12813885d2e6ec4e0a5b72767a83fc164a", + "note": null + }, + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1786817209, + "digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", + "note": "Re-approval: only tasks.md checkboxes changed to reflect completed implementation work; scope, requirements, and risk answers unchanged from the originally-approved definition." + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1786817880, + "digest": "ec2f5be6cd7c274875cd4d19ff9ef9723da327aeb5ad50b5f254732e921311c0", + "note": "Closing approval granted by repository owner via agent-mediated review; verify-native lane green, real end-to-end verification against v1.7.2 and latest performed." + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/change.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/change.md new file mode 100644 index 0000000..8c94340 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +state: accepted +type: feature +base_commit: 6eb7a3ea595c949f4bc0078c0738508d800356b3 +--- + +# Add root-level action.yml GitHub Action for installing fledge in CI + +## Intent + +Add root-level action.yml GitHub Action for installing fledge in CI + +## Affected Canonical Specs + +- None + +## Acceptance Criteria + +- uses: CorvidLabs/fledge@v1 with version: v1.7.2 installs fledge on Linux and macOS runners and makes zero GitHub API calls; version: latest resolves via an authenticated API call; a corrupted download fails the checksum-verification step; Windows runners fail with a readable error instead of a 404 mid-download; the new exercising workflow passes on ubuntu-latest and macos-latest for both a pinned tag and latest; fledge lanes run check passes. + +## No-spec Rationale + +No canonical spec module governs this: .specsync/config.toml scans only source_dirs [src, templates], and the closest existing specs (specs/release for the fledge release command internals, specs/github for fledge's own GitHub API helper module) do not cover the repo's own CI/distribution surface. Only action.yml, a new workflow file, README.md, and CONTRIBUTING.md change; no src/ or templates/ files. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/context.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/context.md new file mode 100644 index 0000000..5ef8692 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/context.md @@ -0,0 +1,62 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: context +--- + +# Context + +`CorvidLabs/fledge` has no `action.yml` on any branch, so `uses: +CorvidLabs/fledge@v1.7.2` fails with "Can't find 'action.yml'". Sibling repo +`CorvidLabs/spec-sync` ships one at its root, which is why `uses: +CorvidLabs/spec-sync@v5.2.0` already works elsewhere — fledge is the odd one +out. + +Consumers currently fall back to curl-piping `install.sh`, whose +`latest_version()` scrapes the unauthenticated +`api.github.com/repos/CorvidLabs/fledge/releases/latest` endpoint. That +endpoint is rate-limited per IP, and GitHub-hosted runners share IPs. +`CorvidLabs/rune` calls it from seven jobs on every push and lost four CI runs +to `could not determine latest version` — on Ruby 3.3, Ruby 3.2, and twice on +Spec Sync — each one indistinguishable from a real failure until someone read +the log. rune has since pinned a direct release-asset download as a +workaround; this change is the real fix, so everyone else gets the same +guarantee without hand-rolling it. + +## Design decisions + +- **Composite, not JS/Docker.** No build step, no bundling, matches the + project's own philosophy (plain HTTP, no CLI-to-install) and `spec-sync`'s + precedent. +- **Pinning makes zero API calls.** A concrete `version` (e.g. `v1.7.2`) + downloads `https://github.com/CorvidLabs/fledge/releases/download/...` + directly — that's a release-asset CDN redirect, not `api.github.com`, so it + isn't subject to the same rate limit at all. Only `version: latest` calls + the API, and it always attaches `github.token` (or a caller-supplied + `token`) so that call lands in the authenticated 5000/hour bucket instead + of the unauthenticated 60/hour-per-IP one. +- **Linux and macOS only, by design, not by omission.** The real v1.7.2 + release actually publishes five binaries including + `fledge-windows-x86_64.exe` (confirmed via `gh release view`), but this + action intentionally supports only `fledge-{linux,macos}-{x86_64,aarch64}` + for its first version — the task scope explicitly calls out Windows as + unsupported ("Windows fails with a readable message" is a listed + acceptance criterion, not a bug to fix). Windows support can be a later, + additive change; the `windows-unsupported` job in `test-action.yml` pins + down the current contract so a future change has to touch it deliberately. +- **Checksum verification degrades gracefully.** Every current release ships + an `.sha256` sidecar; a missing sidecar (a hypothetical release that + predates them) warns and skips rather than hard-failing, so the action + doesn't retroactively break old tags. +- **`uses: ./` in the exercising workflow**, not a published ref — the + workflow must test the code actually on the branch/PR, not whatever `v1` + last pointed at. + +## No spec module + +No canonical spec module governs this: `.specsync/config.toml` scans only +`source_dirs = ["src", "templates"]`, and the closest existing specs +(`specs/release/`, `specs/github/`) cover `fledge release`'s internals and +fledge's own GitHub API helper module respectively — neither is about the +repo's own CI surface. `.specsync/sdd.json` does list `action.yml` and +`.github/` under `meaningful_paths`, which is why this still goes through the +verified change lifecycle even without a spec to update. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/design.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/design.md new file mode 100644 index 0000000..8cd9ccd --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/design.md @@ -0,0 +1,89 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: design +--- + +# Design + +## Shape + +A single-file composite action (`runs.using: composite`), one shell step, +matching `spec-sync`'s own root-level `action.yml` precedent. No JS bundle, +no Docker image, no dependency beyond `curl`/`bash`/`sha256sum`-or-`shasum`, +all already present on GitHub-hosted Linux and macOS runners. + +## Control flow (single `install` step, `set -euo pipefail`) + +``` +guard RUNNER_OS (Linux|macOS, else ::error:: + exit 1) +guard RUNNER_ARCH (X64|ARM64, else ::error:: + exit 1) + │ + ▼ +version == "latest"? + yes → GET api.github.com/.../releases/latest, with Bearer token if set + (Accept + X-GitHub-Api-Version headers; --retry-all-errors) + empty result → ::error:: + exit 1 + no → version is used as-is (no network call yet) + │ + ▼ +curl -fL the release asset directly from github.com/.../releases/download/ + fails → ::error:: naming the exact URL + exit 1 + │ + ▼ +curl the ".sha256" sidecar + found → compare against sha256sum/shasum of the downloaded file + mismatch → ::error:: + exit 1 + missing → ::warning:: (old release, pre-checksums) — do NOT fail + │ + ▼ +install -m 0755 into install-dir; append install-dir to $GITHUB_PATH; +write version/path to $GITHUB_OUTPUT +``` + +A second step (`fledge --version`) runs after PATH is updated, as the smoke +check — kept as its own step (not folded into `install`) so its failure is +visually distinct in the Actions log from an install failure. + +## Inputs / outputs surface + +| Input | Default | Notes | +|---------------|---------------------|----------------------------------------------------------| +| `version` | `latest` | Pin this in CI — see REQ-setup-action-2 | +| `token` | `${{ github.token }}` | Only read when `version: latest` | +| `install-dir` | `` (→ `$HOME/.local/bin`) | Avoids needing `sudo` on either runner OS | + +| Output | Source | +|-----------|--------------------------------------------| +| `version` | The resolved tag (pinned value, or the API result for `latest`) | +| `path` | `/fledge` | + +## Why no jq / no Node + +Matches `install.sh`'s existing `grep '"tag_name"' | cut -d'"' -f4` approach +exactly — same dependency footprint, same failure mode already field-tested +by that script, no new external dependency for a composite action to assume +is present on every consumer's runner image. + +## Exercising workflow shape + +`test-action.yml` has two jobs, not one: the happy-path matrix +(`install`) and a dedicated `windows-unsupported` regression guard using +`continue-on-error: true` + an explicit outcome check, rather than baking a +"this is expected to fail" branch into the matrix itself — keeps the +happy-path matrix's pass/fail meaning unambiguous (green means "it worked" +everywhere in that job), while still running the negative case on every push. + +## Alternatives considered + +- **JS/TypeScript action**: rejected — needs a build step and a committed + `dist/`, which is exactly the kind of tooling fledge itself avoids + (AGENTS.md: "plain HTTP ... no CLI to install"). + `spec-sync`'s own composite action is the closer precedent to follow. +- **Reusing `install.sh` via `curl | bash` from inside the composite + action**: rejected as the implementation strategy — it would just move the + exact rate-limiting bug this change exists to fix one layer down, and + `install.sh` has no checksum verification at all (confirmed by reading it + in full). The action's script intentionally diverges from `install.sh` + where `install.sh` is weaker (adds checksum verification, adds the + authenticated-latest path) while keeping the same asset-naming and + tag-resolution conventions. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/docs.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/docs.md new file mode 100644 index 0000000..ba76d75 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/docs.md @@ -0,0 +1,41 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: docs +--- + +# Docs + +## README.md + +New `## GitHub Actions` section, placed right after `## Install` (before +`## Quick start`), leading with the pinned form per the task requirement: + +```yaml +- uses: CorvidLabs/fledge@v1 + with: + version: v1.7.2 +``` + +followed by a short explanation of the pin-vs-latest tradeoff and the +outputs shape. + +## CONTRIBUTING.md + +New "Moving the `v1` tag" subsection under `## Release Process`, documenting +`git tag -f v1 v && git push origin v1 --force` as a manual +post-release step, since `fledge release` does not do this automatically. + +## AGENTS.md + +Not updated by this change. `AGENTS.md` documents fledge's own `--json` +command surface for agents; a GitHub Action for installing the binary isn't +part of that surface. If a follow-up wants to fold "how to install fledge in +CI" into the agent-facing doc too, that's an easy additive change, not a +prerequisite for this one. + +## No docs-site (`site/src/content/docs/`) change + +Out of scope for this change per the task's explicit ask (README section +only). `site/src/content/docs/getting-started/installation.md` currently has +no GitHub Actions section either — worth a follow-up for consistency, but +not bundled here to keep this change's diff to what was asked. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/plan.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/plan.md new file mode 100644 index 0000000..91bd6e8 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/plan.md @@ -0,0 +1,32 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: plan +--- + +# Plan + +1. Write `action.yml` at the repo root: composite action, single `install` + step (OS/arch guard → version resolution → download → checksum verify → + install → PATH/outputs) plus a `fledge --version` smoke-check step. +2. Write `.github/workflows/test-action.yml`: a matrix job over + `{ubuntu-latest, macos-latest} x {v1.7.2, latest}` using `uses: ./` (not a + published ref, so it always tests the current branch), plus a + `windows-unsupported` job that asserts the action fails cleanly on + `windows-latest`. +3. Add a `## GitHub Actions` section to `README.md`, right after `## Install`, + showing the pinned form first per the task's requirement. +4. Add a "Moving the `v1` tag" subsection to `CONTRIBUTING.md`'s Release + Process, documenting `git tag -f v1 v && git push origin v1 + --force` as a manual post-release step. +5. Locally verify the checksum-mismatch and checksum-missing branches of the + install script in isolation (can't corrupt a real GitHub release asset to + test this end-to-end in CI) — see testing.md. +6. Run `fledge lanes run check` (fmt + lint + test); confirm `specsync check` + is unaffected (no `src/`/`templates/` changes). +7. Take this through the change lifecycle: definition approval → implement → + verify → present evidence → closing approval → accept → merge → archive. +8. Note for follow-up (not part of this change's diff): once merged, a + maintainer creates and pushes the `v1` tag pointing at the merge commit, + per the new CONTRIBUTING.md note. That's real repo history, `git tag` + creation should be authorized explicitly. Also expect a `fledge review` + pass on the diff before opening the PR. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/requirements.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/requirements.md new file mode 100644 index 0000000..34cf3c5 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/requirements.md @@ -0,0 +1,53 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: requirements +--- + +# Requirements + +- **REQ-setup-action-1**: `action.yml` SHALL exist at the repository root so + that `uses: CorvidLabs/fledge@` resolves in any consumer workflow. + - Acceptance: `find . -maxdepth 1 -iname action.yml` finds it; `uses: ./` + resolves from a checkout of this repo. + +- **REQ-setup-action-2**: When `version` is a concrete tag (not `latest`), + the action SHALL download the release asset directly and SHALL NOT make + any call to `api.github.com`. + - Acceptance: with `version: v1.7.2`, the only network calls are to + `github.com/CorvidLabs/fledge/releases/download/v1.7.2/...`. + +- **REQ-setup-action-3**: When `version` is `latest`, the action SHALL + resolve the tag via the GitHub releases API and SHALL send the `token` + input (defaulting to `github.token`) as a Bearer credential on that + request. + - Acceptance: an `Authorization: Bearer` header is present on the + `releases/latest` request whenever a non-empty token is available. + +- **REQ-setup-action-4**: The action SHALL support only Linux and macOS on + `x86_64`/`aarch64`. Any other `RUNNER_OS` or `RUNNER_ARCH` SHALL fail the + step with a `::error::`-annotated, human-readable message before any + download is attempted. + - Acceptance: on `windows-latest`, the step fails; no `curl` to a release + asset is attempted; the message identifies the unsupported platform. + +- **REQ-setup-action-5**: The action SHALL verify the downloaded binary + against its published `.sha256` sidecar when one exists, and SHALL + fail the step on a mismatch. When no sidecar exists for a release, it + SHALL warn and continue rather than fail. + - Acceptance: a deliberately corrupted binary with a mismatched sidecar + fails the step with a clear `::error::`; a release lacking a `.sha256` + file only emits a `::warning::`. + +- **REQ-setup-action-6**: Every network call the action makes SHALL pass + `--retry 3 --retry-all-errors` so that transient network flake does not + fail the step. + - Acceptance: `grep -c -- '--retry-all-errors' action.yml` matches every + `curl` invocation in the script. + +- **REQ-setup-action-7**: On success, the action SHALL add the install + directory to `PATH` via `$GITHUB_PATH`, SHALL expose `version` and `path` + as step outputs, and SHALL run `fledge --version` as a smoke check before + the step group completes. + - Acceptance: a subsequent step in the same job can invoke `fledge` + unqualified; `steps..outputs.version` and `.path` are both + non-empty. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json new file mode 100644 index 0000000..0a73a85 --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "id": "CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci", + "slug": "add-root-level-action-yml-github-action-for-installing-fledge-in-ci", + "title": "Add root-level action.yml GitHub Action for installing fledge in CI", + "description": "Add root-level action.yml GitHub Action for installing fledge in CI", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", + "created_at": 1786815735, + "updated_at": 1786817880, + "affected_specs": [], + "affected_paths": [ + "action.yml", + ".github/workflows/test-action.yml", + "README.md", + "CONTRIBUTING.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": true, + "no_spec_change_rationale": "No canonical spec module governs this: .specsync/config.toml scans only source_dirs [src, templates], and the closest existing specs (specs/release for the fledge release command internals, specs/github for fledge's own GitHub API helper module) do not cover the repo's own CI/distribution surface. Only action.yml, a new workflow file, README.md, and CONTRIBUTING.md change; no src/ or templates/ files.", + "acceptance_criteria": [ + "uses: CorvidLabs/fledge@v1 with version: v1.7.2 installs fledge on Linux and macOS runners and makes zero GitHub API calls; version: latest resolves via an authenticated API call; a corrupted download fails the checksum-verification step; Windows runners fail with a readable error instead of a 404 mid-download; the new exercising workflow passes on ubuntu-latest and macos-latest for both a pinned tag and latest; fledge lanes run check passes." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "no" + } +} diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/tasks.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/tasks.md new file mode 100644 index 0000000..328a43a --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/tasks.md @@ -0,0 +1,16 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: tasks +--- + +# Tasks + +- [x] Write `action.yml` at repo root (composite action) +- [x] Write `.github/workflows/test-action.yml` (matrix + windows-unsupported job) +- [x] Add `## GitHub Actions` section to `README.md` +- [x] Add "Moving the `v1` tag" note to `CONTRIBUTING.md` +- [x] Locally verify checksum match/mismatch/missing-sidecar branches +- [x] Locally verify the real pinned + `latest` happy paths end-to-end against the actual v1.7.2 release +- [x] Locally verify the Windows/unsupported-arch guards fail before any network call +- [x] `fledge lanes run check` and `fledge lanes run pre-commit` green +- [x] `specsync check --json` passes diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/testing.md b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/testing.md new file mode 100644 index 0000000..f75128b --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/testing.md @@ -0,0 +1,60 @@ +--- +change: CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci +artifact: testing +--- + +# Testing + +## Automated (continuous, runs on every push) + +`.github/workflows/test-action.yml`: + +- `install` matrix (`ubuntu-latest`/`macos-latest` × `v1.7.2`/`latest`, `uses: + ./` so it always tests the branch's own code): asserts both outputs are + non-empty, a pinned `version` round-trips unchanged, the installed binary + runs, and `fledge` resolves on `PATH`. +- `windows-unsupported`: asserts the step fails on `windows-latest` + (`continue-on-error: true` + checks `outcome == 'failure'`), pinning down + REQ-setup-action-4 as a regression guard. + +## Manual verification performed during this change (local, macOS arm64, +authenticated `gh` session against the real `CorvidLabs/fledge` repo) + +The exact `run:` block was extracted byte-for-byte from `action.yml` and +executed directly (not simulated) against the real v1.7.2 release: + +1. **Pinned happy path** (`version: v1.7.2`, `RUNNER_OS=macOS`, + `RUNNER_ARCH=ARM64`): downloaded `fledge-macos-aarch64`, verified its real + `.sha256` sidecar, installed, `fledge --version` printed `fledge 1.7.2`, + `outputs.version=v1.7.2`. Confirms REQ-setup-action-1/2/5/7. +2. **`latest`, authenticated** (real `gh auth token`): resolved to `v1.7.2` + (matches `git describe --tags --abbrev=0`), same success path. The API + call hit transient `curl (56)`/`(16)` network errors in the sandbox during + this run and `--retry-all-errors` transparently recovered — an + unplanned but direct live demonstration of REQ-setup-action-6, not just a + read of the script. Confirms REQ-setup-action-3. +3. **Unsupported OS** (`RUNNER_OS=Windows`): failed with exit 1 and the + expected `::error::` message, before any `curl` to a release asset ran. + Confirms REQ-setup-action-4. +4. **Unsupported arch** (`RUNNER_ARCH=ARM`): same, exit 1 with a clear + message. Confirms REQ-setup-action-4. +5. **Checksum match/mismatch/missing-sidecar**, isolated: reproduced the + exact `cut`/`sha256sum`/`shasum` comparison logic against a fake binary + + sidecar in the format `release.yml` actually produces + (`sha256sum "$file" | sed "s|.*/||" > "${file}.sha256"`). A matching pair + verifies; a corrupted binary against the original sidecar is correctly + detected and rejected; an absent sidecar file falls through to the + `curl -fsSL` failure branch, which the script already handles as a warn- + and-skip. Confirms REQ-setup-action-5. (Not exercised against a real + corrupted GitHub release asset — can't corrupt production release + infrastructure to test this end-to-end; the isolated reproduction uses + the identical shell commands the action runs.) +6. `python3 -c "import yaml; yaml.safe_load(...)"` on both `action.yml` and + `test-action.yml` — both parse as valid YAML. + +## Rejection signal + +If a pinned `version` ever triggers a request to `api.github.com`, if +`latest` is ever requested without an `Authorization` header when a token is +available, if a checksum mismatch installs anyway, or if the +`windows-unsupported` job in CI ever goes green, the change is wrong. diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json new file mode 100644 index 0000000..452f8aa --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1786817238, + "commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", + "contract_digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", + "workspace_digest": "dc0b6c7f33d78a8c862c97a87e9fa74dcd7697323c0d007fef4c66bb3a532102", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json new file mode 100644 index 0000000..28260bc --- /dev/null +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json @@ -0,0 +1,71 @@ +{ + "timestamp": 1786817238, + "commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", + "contract_digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", + "workspace_digest": "dc0b6c7f33d78a8c862c97a87e9fa74dcd7697323c0d007fef4c66bb3a532102", + "acceptance_input_digest": "32c7dddda1effbe78b0258768b9dea3ebce4fba92dcbe256af1cdd45f6719fe7", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".github/workflows/test-action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "252a0a9d38bb6ccd9aea072ddea6694f377e022285683930638a45d7eb71f11d", + "entry_digest": "e09a496b47d5c9bb5de81d5e57b7e528d722121910ba1edb9999e2d77d88d7bd", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "7011d0028ec3b0acea0cb9b2f7b91b7abe4382986b4b2bf22f4307b4accbdce6", + "entry_digest": "78e4bd79b42a8aa8798ce1047416397125190f82146ed75ce1d20a316b8512de", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "CONTRIBUTING.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1c456009ea1d97960722bd763e9a2f7d43360ea561f0498d3cdcc627752a4b11", + "entry_digest": "18c9720aa0cd159e91318e72534c3d26999a24b47e942437f5f71ebf54b0bdb4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "289ab300914df33a650a5bcea39fa117ec6faa195ddbc21898aa76f6febb18e8", + "entry_digest": "32e5314496d86fc7895ae5b3cd036bea6f9be2a62df28e49f63007ceb1fb6b11", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "4d02a6581c053618af546163be6f382529717af1bc8021551fde2ba1e2789224", + "entry_digest": "ab41aee2dce9827ca24b764d0755fc319b98ec6f1cd30f418e42441ec39c4731", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a633542..382f436 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -179,6 +179,22 @@ fledge release patch # or minor / major / 1.2.3 For the JSON contract (e.g. for scripting), `fledge release --dry-run --json` and `fledge release --json` emit `{schema_version: 1, action: "release", ...}`. +### Moving the `v1` tag + +`action.yml` at the repo root is the "Setup Fledge" GitHub Action; `uses: +CorvidLabs/fledge@v1` is what consumers actually write, so the moving major +tag needs to point at the release commit after every release: + +```bash +git tag -f v1 v +git push origin v1 --force +``` + +`fledge release` does not do this for you — it is a separate, manual step +(or wire it into a post-release workflow, matching `post-release-formula.yml`'s +pattern, if this becomes tedious). Skipping it leaves `@v1` pinned to a stale +commit; consumers who instead pinned a full tag like `@v1.7.2` are unaffected. + ## Code of Conduct Be respectful and constructive. We're building tools, not arguments. Harassment, discrimination, and unconstructive behavior aren't tolerated. diff --git a/README.md b/README.md index 0913105..61c9456 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,27 @@ git clone https://github.com/CorvidLabs/fledge.git && cd fledge && cargo install +## GitHub Actions + +```yaml +- uses: CorvidLabs/fledge@v1 + with: + version: v1.7.2 +``` + +Pin `version` to a release tag and the action downloads that binary directly — +no GitHub API call, so nothing to rate-limit on shared runner IPs. Leave it at +the default `latest` and the action resolves the tag via an authenticated API +call (using `github.token` unless you pass your own `token`). Linux and macOS +runners (`x86_64`/`aarch64`) are supported; every download is checksum-verified +against the release's `.sha256` sidecar. + +```yaml +outputs: + version: # the release tag that was installed + path: # full path to the installed binary +``` + ## Quick start Already have a project? `cd` into it, fledge auto-detects the stack: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..123ef63 --- /dev/null +++ b/action.yml @@ -0,0 +1,125 @@ +name: 'Setup Fledge' +description: 'Install the fledge CLI and put it on PATH.' +branding: + icon: 'feather' + color: 'purple' + +inputs: + version: + description: >- + Release tag to install, such as v1.7.2, or "latest". Pin it in CI: a + concrete tag downloads the release asset directly and makes no API call, + while "latest" has to ask the API which tag that is. + required: false + default: 'latest' + token: + description: >- + Token used only when resolving "latest". Without one that lookup is + unauthenticated and rate-limited per IP, which is shared between runners + — the failure mode is an intermittent "could not determine latest + version" that looks like a broken build. + required: false + default: ${{ github.token }} + install-dir: + description: 'Directory to install into. Added to PATH.' + required: false + default: '' + +outputs: + version: + description: 'The release tag that was installed.' + value: ${{ steps.install.outputs.version }} + path: + description: 'Full path to the installed binary.' + value: ${{ steps.install.outputs.path }} + +runs: + using: 'composite' + steps: + - id: install + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_TOKEN: ${{ inputs.token }} + INPUT_INSTALL_DIR: ${{ inputs.install-dir }} + run: | + set -euo pipefail + + repo="CorvidLabs/fledge" + install_dir="${INPUT_INSTALL_DIR:-$HOME/.local/bin}" + + case "$RUNNER_OS" in + Linux) os="linux" ;; + macOS) os="macos" ;; + *) + echo "::error::fledge publishes no binary for $RUNNER_OS via this action (Linux and macOS only). See https://github.com/${repo}/releases for the full asset list." >&2 + exit 1 + ;; + esac + + case "$RUNNER_ARCH" in + X64) arch="x86_64" ;; + ARM64) arch="aarch64" ;; + *) + echo "::error::fledge publishes no binary for $RUNNER_ARCH (x86_64 and aarch64 only)." >&2 + exit 1 + ;; + esac + + version="$INPUT_VERSION" + if [ "$version" = "latest" ]; then + # Authenticated when a token is available, because the anonymous + # limit is per IP and runners share them. + auth=() + if [ -n "${INPUT_TOKEN:-}" ]; then + auth=(-H "Authorization: Bearer ${INPUT_TOKEN}") + fi + version="$(curl -fsSL --retry 3 --retry-all-errors "${auth[@]}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${repo}/releases/latest" \ + | grep -m1 '"tag_name"' | cut -d'"' -f4 || true)" + if [ -z "$version" ]; then + echo "::error::Could not resolve the latest fledge release. Pin \`version\` to a tag instead." >&2 + exit 1 + fi + fi + + asset="fledge-${os}-${arch}" + base="https://github.com/${repo}/releases/download/${version}" + + mkdir -p "$install_dir" + tmp="$(mktemp -d)" + + if ! curl -fsSL --retry 3 --retry-all-errors "${base}/${asset}" -o "${tmp}/fledge"; then + echo "::error::Failed to download ${asset} for ${version}. Check that the tag exists and publishes this asset: ${base}/${asset}" >&2 + exit 1 + fi + + # Every release ships a .sha256 beside the binary; a corrupted download + # should fail here rather than at the first fledge invocation. + if curl -fsSL --retry 3 --retry-all-errors "${base}/${asset}.sha256" -o "${tmp}/fledge.sha256"; then + expected="$(cut -d' ' -f1 < "${tmp}/fledge.sha256")" + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "${tmp}/fledge" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "${tmp}/fledge" | cut -d' ' -f1)" + fi + if [ "$expected" != "$actual" ]; then + echo "::error::Checksum mismatch for ${asset}: expected ${expected}, got ${actual}." >&2 + exit 1 + fi + else + echo "::warning::No checksum published for ${asset}; skipping verification. This release predates checksum sidecars." + fi + + install -m 0755 "${tmp}/fledge" "${install_dir}/fledge" + rm -rf "$tmp" + + echo "$install_dir" >> "$GITHUB_PATH" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "path=${install_dir}/fledge" >> "$GITHUB_OUTPUT" + echo "Installed fledge ${version} to ${install_dir}/fledge" + + - shell: bash + run: fledge --version From 1e00d34cc73b0142ada1aadb4638a58351240843 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sun, 16 Aug 2026 17:25:31 -0600 Subject: [PATCH 2/2] fix: harden the setup-fledge action after PR #511 review Addresses 0xGaspar's CHANGES_REQUESTED review on #511. Blocker 1 -- `version` was unvalidated and reached a URL curl path-normalizes. Confirmed against real github.com: requesting `.../releases/download/../../../octocat/Hello-World/x` sends `GET /CorvidLabs/octocat/Hello-World/x`, so a `..` in `version` pulls the binary from another repo, which the action then installs 0755 and executes. `version` is now allowlisted to `latest` or a release tag before any use, and the tag resolved from the API is validated the same way. `install-dir` gets the same treatment at lower severity: `..` segments and line breaks rejected (a newline would otherwise inject lines into $GITHUB_PATH/$GITHUB_OUTPUT). Blocker 2 -- checksum verification silently degraded to none. The sidecar fetch's else-branch warned and continued, which cannot distinguish a genuine absence from a suppressed request, and a ::warning:: does not fail a build. Verification is now mandatory: the fetch captures %{http_code} and any non-200 fails the step, with a 404 reported as "no sidecar published". Nothing real is lost -- surveyed all 39 releases: sidecars are universal from v0.9.1 on, v0.6.0-v0.9.0 publish no assets at all, only v0.3.0-v0.5.0 are now refused. `--retry 3` without `--retry-all-errors` on that fetch so a 404 fails in 0.35s rather than burning the backoff. README.md's checksum claim, which promised a guarantee the skip path did not deliver, now matches the implementation. Correctness -- the Windows message implied no Windows binary exists. Every release ships fledge-windows-x86_64.exe. The message now says the action does not support the platform *yet* and points at the binary that does; README.md and the regression job name say the same. Actually wiring Windows up needs .exe handling plus cygpath translation for $GITHUB_PATH and outputs.path, so it stays a deliberate follow-up rather than an untested add-on here. Nits -- jq instead of grep|cut, with the HTTP status surfaced so a rate limit is diagnosable (the failure this action exists to remove); the Bearer token moved from argv to a stdin curl config; `trap 'rm -rf "$tmp"' EXIT` so the temp dir is cleaned on failure paths; `on: push` narrowed to main so a branch push runs the workflow once rather than twice. test-action.yml also gains a `refuses-unsafe-install` job covering the traversal version and the sidecar-less v0.5.0 release, and passes step outcomes/outputs into assertion steps via `env:`. Recorded as CHG-0009: specsync freezes the definition of an already-applied change, and this response genuinely contradicts CHG-0008's REQ-setup-action-5 (warn-and-continue) and -6 (--retry-all-errors everywhere), so it belongs in its own workspace rather than as an edit to a frozen record. CHG-0008 keeps its original definition and had its evidence refreshed via an audited reopen. Both are accepted with `exact` evidence; `fledge lanes run pre-commit` and `fledge trust verify` are green. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-action.yml | 80 ++++++++-- .specsync/change-sequence.json | 4 +- .../approvals.json | 99 ++++++++++++- .../state.json | 2 +- .../verification-attempts.json | 15 ++ .../verification.json | 20 +-- .../approvals.json | 19 +++ .../change.md | 24 +++ .../context.md | 94 ++++++++++++ .../design.md | 114 ++++++++++++++ .../docs.md | 47 ++++++ .../plan.md | 34 +++++ .../requirements.md | 76 ++++++++++ .../research.md | 65 ++++++++ .../state.json | 40 +++++ .../tasks.md | 50 +++++++ .../testing.md | 82 ++++++++++ .../verification-attempts.json | 20 +++ .../verification.json | 61 ++++++++ README.md | 15 +- action.yml | 140 ++++++++++++++---- 21 files changed, 1043 insertions(+), 58 deletions(-) create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/approvals.json create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/change.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/context.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/design.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/docs.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/plan.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/requirements.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/research.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/state.json create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/tasks.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/testing.md create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification-attempts.json create mode 100644 .specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification.json diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 5cf5d08..583335c 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -1,16 +1,17 @@ name: Test Action # Exercises action.yml itself (the root-level composite "Setup Fledge" action), -# not the fledge binary's own test suite (that's ci.yml). Runs on every push -# so a break here is caught immediately — this action exists specifically +# not the fledge binary's own test suite (that's ci.yml). This action exists # because a silent break in the curl-based install workaround cost -# CorvidLabs/rune several CI runs. See AGENTS.md and action.yml. +# CorvidLabs/rune several CI runs, so it is covered on every PR and on main. +# See AGENTS.md and action.yml. permissions: contents: read on: push: + branches: [main] pull_request: branches: [main] workflow_dispatch: @@ -39,35 +40,90 @@ jobs: - name: Verify outputs shell: bash + env: + REQUESTED: ${{ matrix.version }} + GOT_VERSION: ${{ steps.setup.outputs.version }} + GOT_PATH: ${{ steps.setup.outputs.path }} run: | set -euo pipefail - if [ -z "${{ steps.setup.outputs.version }}" ]; then + if [ -z "$GOT_VERSION" ]; then echo "::error::outputs.version is empty" >&2 exit 1 fi - if [ -z "${{ steps.setup.outputs.path }}" ]; then + if [ -z "$GOT_PATH" ]; then echo "::error::outputs.path is empty" >&2 exit 1 fi # A pinned version must round-trip unchanged; "latest" must resolve # to a real vX.Y.Z tag rather than passing the literal through. - if [ "${{ matrix.version }}" != "latest" ] && [ "${{ steps.setup.outputs.version }}" != "${{ matrix.version }}" ]; then - echo "::error::pinned version ${{ matrix.version }} but outputs.version was ${{ steps.setup.outputs.version }}" >&2 + if [ "$REQUESTED" != "latest" ] && [ "$GOT_VERSION" != "$REQUESTED" ]; then + echo "::error::pinned version ${REQUESTED} but outputs.version was ${GOT_VERSION}" >&2 exit 1 fi - "${{ steps.setup.outputs.path }}" --version + "$GOT_PATH" --version command -v fledge + refuses-unsafe-install: + name: refuses unsafe and unverifiable installs + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + + # curl normalizes dot segments before sending, so an unvalidated `..` + # here would pull the binary from a different repo entirely — and the + # action installs it 0755 and runs it. + - id: traversal + uses: ./ + continue-on-error: true + with: + version: ../../../octocat/Hello-World/releases/download/v1 + + # v0.5.0 really does publish fledge-linux-x86_64 with no .sha256 beside + # it, which makes it a live fixture for the missing-sidecar path: the + # install has to fail rather than quietly degrade to unverified. + - id: unverifiable + uses: ./ + continue-on-error: true + with: + version: v0.5.0 + + - name: Confirm both were refused + shell: bash + env: + TRAVERSAL: ${{ steps.traversal.outcome }} + UNVERIFIABLE: ${{ steps.unverifiable.outcome }} + run: | + set -euo pipefail + failed=0 + + if [ "$TRAVERSAL" != "failure" ]; then + echo "::error::a version containing '..' was accepted; the version validation in action.yml has regressed" >&2 + failed=1 + fi + if [ "$UNVERIFIABLE" != "failure" ]; then + echo "::error::v0.5.0 publishes no .sha256 sidecar but installed anyway; checksum verification is no longer mandatory" >&2 + failed=1 + fi + + if [ "$failed" -ne 0 ]; then + exit 1 + fi + echo "Confirmed: traversal version rejected, unverifiable release refused." + windows-unsupported: - name: windows fails with a readable message + name: windows is refused with a readable message runs-on: windows-latest timeout-minutes: 5 steps: - uses: actions/checkout@v5 + # fledge does publish fledge-windows-x86_64.exe; this action just doesn't + # install it yet. The message the action prints has to say that, not + # imply the binary is missing. - id: setup uses: ./ continue-on-error: true @@ -76,10 +132,12 @@ jobs: - name: Confirm it failed for the right reason shell: bash + env: + OUTCOME: ${{ steps.setup.outcome }} run: | set -euo pipefail - if [ "${{ steps.setup.outcome }}" != "failure" ]; then + if [ "$OUTCOME" != "failure" ]; then echo "::error::expected the action to fail on windows-latest, but it succeeded. If Windows support was added, update this job and README.md." >&2 exit 1 fi - echo "Confirmed: the action fails cleanly on Windows, as expected." + echo "Confirmed: the action refuses Windows cleanly, as expected." diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index b582799..e77137e 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 8, - "id": "CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci", + "sequence": 9, + "id": "CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json index 9d79d86..c99ab00 100644 --- a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/approvals.json @@ -20,7 +20,104 @@ "timestamp": 1786817880, "digest": "ec2f5be6cd7c274875cd4d19ff9ef9723da327aeb5ad50b5f254732e921311c0", "note": "Closing approval granted by repository owner via agent-mediated review; verify-native lane green, real end-to-end verification against v1.7.2 and latest performed." + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1786922144, + "digest": "65d6d9bccba114e9e15d5da5ad19fa5959dd5e8fa63f51bad86c0b3fb43b1794", + "note": "Evidence refreshed after the PR #511 review response. CHG-0008's definition is byte-identical to what was originally accepted; the amended requirements live in CHG-0009, since specsync freezes the definition of an applied change. verify-native lane green against the current tree." } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci", + "actor": "0xLeif", + "reason": "PR #511 review response (CHG-0009) amended action.yml, .github/workflows/test-action.yml and README.md after CHG-0008 was accepted, and creating CHG-0009 bumped .specsync/change-sequence.json, which CHG-0008 also lists as a delivery input. CHG-0008's definition is unchanged and stays frozen; this reopen only refreshes its verification evidence against the current tree.", + "timestamp": 1786921833, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1786817880, + "digest": "ec2f5be6cd7c274875cd4d19ff9ef9723da327aeb5ad50b5f254732e921311c0", + "note": "Closing approval granted by repository owner via agent-mediated review; verify-native lane green, real end-to-end verification against v1.7.2 and latest performed." + }, + "prior_verification": { + "timestamp": 1786817238, + "commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", + "contract_digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", + "workspace_digest": "dc0b6c7f33d78a8c862c97a87e9fa74dcd7697323c0d007fef4c66bb3a532102", + "acceptance_input_digest": "32c7dddda1effbe78b0258768b9dea3ebce4fba92dcbe256af1cdd45f6719fe7", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".github/workflows/test-action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "252a0a9d38bb6ccd9aea072ddea6694f377e022285683930638a45d7eb71f11d", + "entry_digest": "e09a496b47d5c9bb5de81d5e57b7e528d722121910ba1edb9999e2d77d88d7bd", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "7011d0028ec3b0acea0cb9b2f7b91b7abe4382986b4b2bf22f4307b4accbdce6", + "entry_digest": "78e4bd79b42a8aa8798ce1047416397125190f82146ed75ce1d20a316b8512de", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "CONTRIBUTING.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1c456009ea1d97960722bd763e9a2f7d43360ea561f0498d3cdcc627752a4b11", + "entry_digest": "18c9720aa0cd159e91318e72534c3d26999a24b47e942437f5f71ebf54b0bdb4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "289ab300914df33a650a5bcea39fa117ec6faa195ddbc21898aa76f6febb18e8", + "entry_digest": "32e5314496d86fc7895ae5b3cd036bea6f9be2a62df28e49f63007ceb1fb6b11", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "4d02a6581c053618af546163be6f382529717af1bc8021551fde2ba1e2789224", + "entry_digest": "ab41aee2dce9827ca24b764d0755fc319b98ec6f1cd30f418e42441ec39c4731", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "32c7dddda1effbe78b0258768b9dea3ebce4fba92dcbe256af1cdd45f6719fe7", + "current_acceptance_input_digest": "3c9258d80c731e9b7381238fd82458164bba50b77180afe366a72e4f6c2ff2d2" + } + ] } diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json index 0a73a85..e5b5740 100644 --- a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", "created_at": 1786815735, - "updated_at": 1786817880, + "updated_at": 1786922144, "affected_specs": [], "affected_paths": [ "action.yml", diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json index 452f8aa..fa2ba0e 100644 --- a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification-attempts.json @@ -15,6 +15,21 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1786922137, + "commit": "a868dba40fe1d42dba62dd21bd8358aed1a57eb7", + "contract_digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", + "workspace_digest": "49e8f338b9c2e32fc09c3918f0275fbe5a6b647feca65685cf9178526b28d83d", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json index 28260bc..769a9ca 100644 --- a/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json +++ b/.specsync/changes/CHG-0008-add-root-level-action-yml-github-action-for-installing-fledge-in-ci/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1786817238, - "commit": "6eb7a3ea595c949f4bc0078c0738508d800356b3", + "timestamp": 1786922137, + "commit": "a868dba40fe1d42dba62dd21bd8358aed1a57eb7", "contract_digest": "81d6b1c401a7ef49d768e85aae57caf9905f1e5f8784d7b879866b99bc1c8444", - "workspace_digest": "dc0b6c7f33d78a8c862c97a87e9fa74dcd7697323c0d007fef4c66bb3a532102", - "acceptance_input_digest": "32c7dddda1effbe78b0258768b9dea3ebce4fba92dcbe256af1cdd45f6719fe7", + "workspace_digest": "49e8f338b9c2e32fc09c3918f0275fbe5a6b647feca65685cf9178526b28d83d", + "acceptance_input_digest": "3c9258d80c731e9b7381238fd82458164bba50b77180afe366a72e4f6c2ff2d2", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -11,8 +11,8 @@ "path": ".github/workflows/test-action.yml", "kind": "file", "mode": 33188, - "payload_digest": "252a0a9d38bb6ccd9aea072ddea6694f377e022285683930638a45d7eb71f11d", - "entry_digest": "e09a496b47d5c9bb5de81d5e57b7e528d722121910ba1edb9999e2d77d88d7bd", + "payload_digest": "5950570b82122806690a816c768369c1ae29b8acc4642cdc80a97a2b29d369f4", + "entry_digest": "425111ea3e1cfeaafb924945c6dbfdc348c55518b41fbae872cc3f89669a6293", "owners": [ "@exact:delivery" ] @@ -41,8 +41,8 @@ "path": "README.md", "kind": "file", "mode": 33188, - "payload_digest": "289ab300914df33a650a5bcea39fa117ec6faa195ddbc21898aa76f6febb18e8", - "entry_digest": "32e5314496d86fc7895ae5b3cd036bea6f9be2a62df28e49f63007ceb1fb6b11", + "payload_digest": "5609822315d9f4b4e89f96928c6ee16b5d40e7b33573015b4c464f14f3661c1e", + "entry_digest": "cf4d107be5e03c03e0e332243ae42b854667cd0afc21bc154f1f035c46f53be1", "owners": [ "@exact:delivery" ] @@ -51,8 +51,8 @@ "path": "action.yml", "kind": "file", "mode": 33188, - "payload_digest": "4d02a6581c053618af546163be6f382529717af1bc8021551fde2ba1e2789224", - "entry_digest": "ab41aee2dce9827ca24b764d0755fc319b98ec6f1cd30f418e42441ec39c4731", + "payload_digest": "1ee83d2a24c4bc3e88a645e95da58a8c1a7c77c19acf57fc407f76dfd2a36fc4", + "entry_digest": "fcdc56208fb18ba639964f77d88de32900d75a9b50b6928279ad0b8053fd994a", "owners": [ "@exact:delivery" ] diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/approvals.json b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/approvals.json new file mode 100644 index 0000000..599db87 --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/approvals.json @@ -0,0 +1,19 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1786921529, + "digest": "3164717310eb979344b205f92b6244df36ce362d679f5f700bba1129971f43d5", + "note": "Definition approval for the PR #511 review response. Scope is the three files CHG-0008 delivered (action.yml, test-action.yml, README.md); REQ-harden-action-3/4/5 explicitly replace or amend CHG-0008's REQ-setup-action-5/6/4, whose definition is frozen by acceptance. public_contract answered 'no' in the spec-contract sense only - see context.md." + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1786921732, + "digest": "3b4af7b4a11434984df9e758e6f2a8b0f083cf2b0dd4d78b651228cdd5784722", + "note": "Closing approval for the PR #511 review response, granted by repository owner via agent-mediated review. verify-native lane green; every branch of the install script re-verified locally against the real CorvidLabs/fledge releases (pinned, latest anonymous/authenticated/bad-token, traversal version, install-dir with .. and with a newline, sidecar-less v0.5.0, checksum mismatch, malformed sidecar, Windows, unsupported arch), shellcheck clean, temp-dir cleanup confirmed on failure paths." + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/change.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/change.md new file mode 100644 index 0000000..1cdddbb --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +state: accepted +type: bug_fix +base_commit: a868dba40fe1d42dba62dd21bd8358aed1a57eb7 +--- + +# Harden the setup-fledge composite action after PR #511 review: validate the version and install-dir inputs, make checksum verification mandatory, and correct the Windows and README claims + +## Intent + +Harden the setup-fledge composite action after PR #511 review: validate the version and install-dir inputs, make checksum verification mandatory, and correct the Windows and README claims + +## Affected Canonical Specs + +- None + +## Acceptance Criteria + +- A version input that is not 'latest' or a release tag is rejected before any network call, so a value containing '..' can no longer redirect the download to another repository; install-dir is rejected when it contains a '..' segment or a line break; the checksum sidecar is fetched with its HTTP status captured and any non-200 fails the step, so no input or network outcome installs an unverified binary; the unsupported-platform error states that the action does not support the platform yet and points at the Windows binary every release does publish; the latest lookup parses tag_name with jq and names the HTTP status on failure; the token is passed through a stdin curl config rather than argv; the temp directory is removed on every exit path; test-action.yml gains a refuses-unsafe-install job covering the traversal version and the sidecar-less v0.5.0 release, and its push trigger is narrowed to main so a branch push runs it once; README.md no longer claims a checksum guarantee the script does not deliver; fledge lanes run pre-commit is green. + +## No-spec Rationale + +No canonical spec module governs this: .specsync/config.toml scans only source_dirs [src, templates], and this change touches only the repo's own CI/distribution surface (action.yml, its exercising workflow, and the README section describing them). No src/ or templates/ files change. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/context.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/context.md new file mode 100644 index 0000000..e9e236c --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/context.md @@ -0,0 +1,94 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: context +--- + +# Context + +CHG-0008 added the root-level `action.yml` composite action. PR #511 review +(0xGaspar, `CHANGES_REQUESTED`) found two blockers, one factual error in the +error text and docs, and four nits. This change is the response. It amends the +behavior CHG-0008 specified — CHG-0008's own record is frozen at what was +accepted then, which is why this is a separate workspace rather than an edit +to it. + +## What the review found + +1. **`version` was unvalidated and reached a URL curl path-normalizes.** The + input is interpolated into + `https://github.com/CorvidLabs/fledge/releases/download/${version}/...`, + and curl resolves dot segments client-side before sending. Verified against + real github.com: requesting `.../releases/download/../../../octocat/Hello-World/x` + sends `GET /CorvidLabs/octocat/Hello-World/x`. With one more `../`, a + `version` of `../../../../attacker/repo/releases/download/v1` resolves to + `/attacker/repo/releases/download/v1/fledge-linux-x86_64`, which the action + then `install -m 0755`es and executes via its own `fledge --version` smoke + check. + +2. **Checksum verification silently degraded to none.** The sidecar fetch's + `else` branch emitted a `::warning::` and continued, on the theory that the + release predated sidecars. That branch could not distinguish a genuine + absence from a request that was made to fail, and a `::warning::` does not + fail a build — so anyone able to tamper with the binary could also suppress + its verification. README.md meanwhile claimed "every download is + checksum-verified", a guarantee the code did not deliver. + +3. **The Windows message implied no Windows binary exists.** It read "fledge + publishes no binary for Windows via this action". Every release ships + `fledge-windows-x86_64.exe` and its sidecar. Combined with the test job + named "windows fails with a readable message" and the README's platform + list, a reader would reasonably conclude the binary is unavailable. + +4. Nits: fragile `grep`/`cut` JSON parsing with a `|| true` that collapsed + auth failure, rate limiting and network loss into one message; the Bearer + token in argv; `mktemp -d` leaking on failure paths; an unfiltered + `on: push` giving every branch push two workflow runs. + +## Design decisions + +- **Untrusted input stops at the action boundary, not in the consumer's + workflow.** The traversal only bites a consumer who feeds untrusted data + into `version`, but `pull_request_target` keyed off a branch name, label, or + PR title is the standard way that happens, and this is a public action + inviting third-party use. A tag-shaped allowlist costs one `grep -Eq`. + +- **No skip path at all, rather than a smarter skip path.** The review offered + three options: capture the status and treat 404 as legitimate, gate the skip + on a version floor, or add an `allow-unverified` input. All three keep a + code path that installs unverified binaries. A survey of all 39 releases + shows that path protects nothing: sidecars are universal from **v0.9.1** + onward, v0.6.0–v0.9.0 publish no assets at all, and only v0.3.0–v0.5.0 have + binaries without them. The status is still captured, but only so the error + can say *why* — a 404 is reported as "no sidecar published there". + +- **Windows: fix the message, defer the support.** Wiring Windows up needs + `.exe` handling plus `cygpath` translation for `$GITHUB_PATH` and + `outputs.path`, since an MSYS-style `/c/...` entry is not usable from `pwsh` + steps. That is a platform's worth of new surface and is better done + deliberately as its own change. What was actually wrong here was the claim, + so the claim is what this change fixes, in the error text and in README.md. + +- **`jq` is fine inside an action.** CHG-0008 chose `grep '"tag_name"' | cut` + to mirror `install.sh`'s dependency footprint. `jq` is preinstalled on every + GitHub-hosted runner, so that argument does not apply here, and the string + slicing needed a `|| true` that destroyed exactly the diagnostic this action + exists to provide. + +## No spec module + +Unchanged from CHG-0008: `.specsync/config.toml` scans only +`source_dirs = ["src", "templates"]`, and no canonical spec covers the repo's +own CI/distribution surface. `.specsync/sdd.json` lists `action.yml` and +`.github/` under `meaningful_paths`, which is why this still goes through the +verified change lifecycle. + +The `public_contract` interview answer is `no`, matching CHG-0008, and that is +narrower than it may read. The *action's* observable behavior does change — a +`version` value that used to be passed through is now rejected, and a +sidecar-less release that used to install with a warning now fails. But +`public_contract` in this interview asks whether a **canonical spec contract** +moves, and there is no spec module owning this surface for one to move. The +behavior changes are captured in requirements (REQ-harden-action-1 and -3), +in the README, and in this record; `no_spec_change` is the accurate answer to +the question actually being asked, not a claim that nothing observable +changed. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/design.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/design.md new file mode 100644 index 0000000..c82d22e --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/design.md @@ -0,0 +1,114 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: design +--- + +# Design + +The shape from CHG-0008 is unchanged: one composite action, one shell step +plus a smoke-check step, `set -euo pipefail`, no JS or Docker. This change +adds a validation prologue, hardens the checksum stage, and corrects two +messages. `jq` joins `curl`/`bash`/`sha256sum`-or-`shasum` as an assumed +dependency — preinstalled on every GitHub-hosted runner. + +## Revised control flow + +``` +validate version (latest | ^v?X.Y.Z(-|+suffix)?$, else ::error:: + exit 1) ← new +validate install-dir (no `..` segment, no line break, else ::error:: + exit 1) ← new + │ + ▼ +guard RUNNER_OS (Linux|macOS, else ::error:: + exit 1) ← message corrected +guard RUNNER_ARCH (X64|ARM64, else ::error:: + exit 1) + │ + ▼ +mktemp -d + trap 'rm -rf "$tmp"' EXIT ← new + │ + ▼ +version == "latest"? + yes → GET api.github.com/.../releases/latest + curl --config - from stdin, carrying the Bearer header ← was argv + capture %{http_code}; non-200 → ::error:: naming the status ← was `|| true` + jq -r '.tag_name' ← was grep|cut + re-validate the resolved tag ← new + no → version is used as-is (no network call) + │ + ▼ +curl -fL the release asset (--retry 3 --retry-all-errors) + │ + ▼ +curl the sidecar (--retry 3, NO --retry-all-errors), capture %{http_code} + non-200 → ::error:: + exit 1 ← was ::warning:: + continue + not 64 hex chars → ::error:: + exit 1 ← new + mismatch → ::error:: + exit 1 + │ + ▼ +install -m 0755; append to $GITHUB_PATH; write $GITHUB_OUTPUT +``` + +## Why an allowlist rather than escaping or normalizing + +The value is not quoted into a shell command — it is interpolated into a URL, +and the normalization happens inside curl, on the client, before the request +leaves the runner. There is nothing to escape: `%2e%2e` would not help a +consumer who wants a legitimate tag, and rejecting only `..` would leave the +input free to point at any other path shape github.com happens to serve. A +tag-shaped allowlist is the only form that says what the input is *for*. The +same regex is reapplied to the API-resolved tag so both entry points converge +on one rule. + +`install-dir` gets a narrower guard rather than the same allowlist, because a +directory path legitimately has no fixed shape. Two concrete hazards are +closed: a `..` segment writing a 0755 binary outside the intended tree, and a +line break injecting extra lines into `$GITHUB_PATH`/`$GITHUB_OUTPUT` (an +`install-dir` of `ok\nversion=fake` would otherwise forge a step output). + +## Why the checksum skip is removed rather than narrowed + +A skip branch that fires on a failed fetch is indistinguishable from a skip +branch that fires on an attack: whoever can substitute the binary can also +make the sidecar request fail, and a `::warning::` does not fail the build. +Narrowing it — 404-only, or below a version floor — keeps the unverified-install +code path alive to serve v0.3.0–v0.5.0, three pre-1.0 releases. Removing it +costs those three releases and nothing else (see research.md). The status code +is still captured, but only to make the error accurate: a 404 says "no sidecar +is published there — releases before v0.9.1 predate them", anything else +reports the status and curl's exit code. + +`--retry 3` on the sidecar fetch is deliberately *without* `--retry-all-errors`: +`--retry` alone covers the transient statuses (408, 429, 5xx) but not 404, so +a genuinely absent sidecar returns its verdict in ~0.3s instead of after the +full backoff. The binary download keeps `--retry-all-errors`, where a 404 is +fatal anyway. + +## Why `-w '%{http_code}'` with `-f`, not one or the other + +`-f` alone gives an exit code but no status, so 401, 403, 429 and 500 are +indistinguishable — which is the diagnostic CHG-0008 exists to provide, lost. +`-w` alone would make curl treat a 404 body as a successful transfer. Together, +curl still writes the status to stdout when `--fail` aborts it (verified: exit +22/56 with `404` on stdout; a DNS failure gives `000`), so both signals are +available and the error can name the real cause. + +## Test workflow + +Adds a third job, `refuses-unsafe-install`, holding both negative cases in one +runner: the traversal `version`, and `v0.5.0` as a live sidecar-less fixture. +Using a real release rather than a mock means the missing-sidecar path is +exercised against production data. Both use `continue-on-error: true` and an +explicit outcome assertion, so the happy-path matrix keeps its unambiguous +"green means it worked" meaning. + +`on: push` is narrowed to `main`; combined with `pull_request` to `main`, a +branch push now produces one run instead of two. Step outcomes and outputs are +passed into assertion steps through `env:` rather than interpolated into the +`run:` block, matching how the action itself already handles its inputs. + +## Deferred: Windows support + +The asset exists, so this is genuinely available — but it needs `.exe` +handling, `install` vs `cp`+`chmod` under Git Bash, and `cygpath -w` for +`$GITHUB_PATH` and `outputs.path`, because an MSYS-style `/c/...` entry is not +usable from `pwsh` steps and would half-work in a way that is worse than a +clean refusal. Left as an additive follow-up; this change makes the refusal +message tell the truth in the meantime. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/docs.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/docs.md new file mode 100644 index 0000000..50d3240 --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/docs.md @@ -0,0 +1,47 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: docs +--- + +# Docs + +## README.md — `## GitHub Actions` + +CHG-0008's section claimed: + +> every download is checksum-verified against the release's `.sha256` sidecar + +which the warn-and-skip path did not deliver. The docs promised a guarantee +the script did not keep, and the reviewer was right that one of the two had to +move. The guarantee is now real, so the sentence is rewritten to describe it +precisely rather than softened: verification is mandatory, a missing, +unfetchable, or mismatched checksum fails the step, and releases before +`v0.9.1` predate the sidecars and therefore cannot be installed this way. + +Two further corrections in the same section: + +- `version` accepts a release tag or `latest` and nothing else, stated + alongside the reason (the value reaches a download URL). +- Windows is described as not supported by the action *yet*, with a pointer to + the `fledge-windows-x86_64.exe` that every release does publish — replacing + a platform list that a reader could take as "no Windows binary exists". + +## CONTRIBUTING.md + +Unchanged. The "Moving the `v1` tag" subsection CHG-0008 added is still +accurate and still the manual post-release step. + +## action.yml input descriptions + +The `version` input's own description now states that anything other than a +release tag or `latest` is rejected, so the constraint is discoverable from +the action's inputs — where a consumer writing `with:` will actually look — +and not only from the README. + +## AGENTS.md and the docs site + +Not updated, unchanged from CHG-0008's reasoning: `AGENTS.md` documents +fledge's `--json` command surface, and +`site/src/content/docs/getting-started/installation.md` has no GitHub Actions +section to correct. Folding "how to install fledge in CI" into either remains +an additive follow-up. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/plan.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/plan.md new file mode 100644 index 0000000..4d3e2ae --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/plan.md @@ -0,0 +1,34 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: plan +--- + +# Plan + +1. Survey sidecar coverage across every release before touching the checksum + logic — the decision between "remove the skip" and "narrow the skip" turns + entirely on how many real releases the skip protects (research.md). +2. Confirm the traversal against real github.com rather than reasoning about + it, and confirm the `-f` + `-w '%{http_code}'` and `--retry`-vs-`--retry-all-errors` + semantics the new error handling depends on. +3. `action.yml`: add the validation prologue (`version` allowlist, + `install-dir` guards), the `trap` cleanup, the `jq` + status-capturing + `latest` lookup with the token moved to a stdin curl config, and the + mandatory checksum stage. Correct the unsupported-platform message. +4. `.github/workflows/test-action.yml`: add the `refuses-unsafe-install` job, + narrow `on: push` to `main`, pass step outcomes/outputs through `env:`. +5. `README.md`: replace the checksum claim with what the script actually does, + note the `version` input's accepted forms, and describe Windows as not + supported *yet* with a pointer to the binary that ships. +6. Verify locally by extracting the `run:` block byte-for-byte from + `action.yml` and executing it against the real releases — every happy path + and every refusal branch, plus `shellcheck` and a temp-dir leak check + (testing.md). +7. `fledge lanes run pre-commit`, then the change lifecycle: definition + approval → implement → verify → closing approval → accept. +8. Refresh CHG-0008's evidence with an audited `reopen → verify → accept`. + Its definition is frozen and stays byte-identical; this workspace exists + precisely because the definition needed to move. +9. Not in scope, deliberately: Windows support (see design.md), and the moving + `v1` tag, which remains the manual post-merge step CHG-0008 documented in + CONTRIBUTING.md. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/requirements.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/requirements.md new file mode 100644 index 0000000..a50625f --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/requirements.md @@ -0,0 +1,76 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: requirements +--- + +# Requirements + +These amend CHG-0008's REQ-setup-action-4, -5 and -6 and add four new +obligations. CHG-0008's REQ-1, -2, -3 and -7 are unchanged and still hold. + +- **REQ-harden-action-1**: The `version` input SHALL be rejected before it is + used, unless it is `latest` or matches + `^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$`. A tag resolved from the + API SHALL be validated the same way. + - Acceptance: `version: ../../../octocat/Hello-World/releases/download/v1` + fails with `::error::Invalid version` and makes no network call; + `v1.7.2`, `1.7.2`, `v1.8.0-rc.1` and `latest` are all accepted. + +- **REQ-harden-action-2**: The `install-dir` input SHALL be rejected when it + contains a `..` path segment or a line break. + - Acceptance: `install-dir: /../escape/bin` fails; an + `install-dir` containing a newline fails, since it would otherwise inject + additional lines into `$GITHUB_PATH` and `$GITHUB_OUTPUT`. + +- **REQ-harden-action-3** (replaces CHG-0008 REQ-setup-action-5): The action + SHALL verify the downloaded binary against its published `.sha256` + sidecar and SHALL fail the step whenever that verification cannot be + completed — sidecar unfetchable for any reason, response not a sha256 + digest, or digest mismatch. There SHALL be no input and no network outcome + that results in an installed but unverified binary. + - Acceptance: `version: v0.5.0` (a real release publishing + `fledge-linux-x86_64` with no sidecar) fails rather than warning and + continuing; a 200 response that is not 64 hex characters fails; a digest + mismatch fails naming both digests. + +- **REQ-harden-action-4** (replaces CHG-0008 REQ-setup-action-6): Every + network call SHALL retry transient failures. The sidecar fetch SHALL NOT + retry a `404`, so an absent checksum is reported as absent rather than + consuming the retry backoff first. + - Acceptance: the binary download and the API lookup pass + `--retry-all-errors`; the sidecar fetch passes `--retry 3` without it and + returns its 404 verdict in well under a second. + +- **REQ-harden-action-5** (amends CHG-0008 REQ-setup-action-4): The + unsupported-platform error SHALL state that *the action* does not support + the platform yet, and SHALL NOT imply that fledge publishes no binary for + it. It SHALL point at the releases page where that binary is available. + - Acceptance: on `windows-latest` the step still fails before any + release-asset request, and the message names the platform, says "yet", and + references `fledge-windows-x86_64.exe`. + +- **REQ-harden-action-6** (extends CHG-0008 REQ-setup-action-3): The `latest` + lookup SHALL parse `tag_name` with `jq`, SHALL report the HTTP status when + the request does not return 200, and SHALL NOT place the token in the + process arguments of any command. + - Acceptance: an invalid token produces an error naming HTTP 401 (which also + proves the Bearer header was sent); a rate-limited response is + distinguishable from a network failure in the log; the token appears only + on a curl config file read from stdin. + +- **REQ-harden-action-7**: The temporary download directory SHALL be removed + on every exit path, including failures. + - Acceptance: running each failing branch leaves the system temp-directory + count unchanged. + +- **REQ-harden-action-8**: `test-action.yml` SHALL cover the refusal paths as + regression guards, and SHALL NOT produce two runs for a single branch push. + - Acceptance: a `refuses-unsafe-install` job asserts that both a traversal + `version` and the sidecar-less `v0.5.0` fail; `on:` is `push` to `main` + plus `pull_request` to `main`. + +- **REQ-harden-action-9**: `README.md` SHALL NOT state a checksum guarantee + stronger than the implementation provides. + - Acceptance: the `## GitHub Actions` section describes verification as + mandatory and says releases before v0.9.1 cannot be installed this way, + which is what the script does. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/research.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/research.md new file mode 100644 index 0000000..b98cf26 --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/research.md @@ -0,0 +1,65 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: research +--- + +# Research + +Everything below was measured against the real `CorvidLabs/fledge` repo and a +real `curl`, not inferred from documentation. + +## 1. Sidecar coverage across every release + +`gh release list --limit 100` → 39 releases, each queried with +`gh release view --json assets`: + +| Releases | Binaries | Sidecars | +|----------|----------|----------| +| v1.4.0 – v1.7.2 | 5 | 5 | +| v0.9.1 – v1.3.1 | 4 | 4 | +| v0.6.0 – v0.9.0 | 0 | 0 | +| v0.3.0 – v0.5.0 | 4 | 0 | + +Conclusion: making verification mandatory refuses exactly v0.3.0, v0.4.0 and +v0.5.0. v0.6.0–v0.9.0 publish no assets at all, so the action could never have +installed them regardless. This is what makes removing the skip path cheap. +v0.5.0 doubles as the CI fixture for the missing-sidecar case: it really does +publish `fledge-linux-x86_64` with nothing beside it. + +## 2. The traversal is real, not theoretical + +``` +$ curl -sv "https://github.com/CorvidLabs/fledge/releases/download/../../../octocat/Hello-World/x" +> GET /CorvidLabs/octocat/Hello-World/x HTTP/2 +``` + +curl resolves the dot segments before sending. The request that leaves the +runner is for a different repository's path, so `version` reaches further than +the tag position it appears to occupy. + +## 3. curl flag semantics the script depends on + +- `-f` + `-w '%{http_code}'`: curl still writes the status code to stdout when + `--fail` aborts the transfer. A 404 gives exit 56 (or 22) with `404` on + stdout; a DNS failure gives exit 6 with `000`. Both signals are therefore + available at once, which is what lets the error distinguish "no sidecar + published" from "the request never completed". +- `--retry 3` alone does **not** retry a 404: the sidecar 404 returns in 0.35s. + Adding `--retry-all-errors` makes the same 404 retry four times through the + backoff. Hence the deliberate asymmetry between the two fetches. +- `--config -` reads options from stdin, so `header = "Authorization: Bearer …"` + never appears in argv. Confirmed effective by the bad-token case returning + HTTP 401 — an anonymous request would have returned 200. + +## 4. Windows + +`gh release view v1.7.2 --json assets` lists `fledge-windows-x86_64.exe` and +`fledge-windows-x86_64.exe.sha256`. The reviewer's factual correction is +right: the binary ships, and CHG-0008's error text implied otherwise. + +## 5. `jq` availability + +`jq` is preinstalled on `ubuntu-latest`, `macos-latest` and `windows-latest` +GitHub-hosted images, so using it inside the action adds no install step. The +dependency-footprint argument that justified `grep | cut` in CHG-0008 applies +to `install.sh` (which runs on arbitrary user machines), not here. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/state.json b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/state.json new file mode 100644 index 0000000..229b807 --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/state.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "id": "CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers", + "slug": "harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers", + "title": "Harden the setup-fledge composite action after PR #511 review: validate the version and install-dir inputs, make checksum verification mandatory, and correct the Windows and README claims", + "description": "Harden the setup-fledge composite action after PR #511 review: validate the version and install-dir inputs, make checksum verification mandatory, and correct the Windows and README claims", + "kind": "bug_fix", + "state": "accepted", + "canonical_applied": true, + "base_commit": "a868dba40fe1d42dba62dd21bd8358aed1a57eb7", + "created_at": 1786921298, + "updated_at": 1786921732, + "affected_specs": [], + "affected_paths": [ + "action.yml", + ".github/workflows/test-action.yml", + "README.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": true, + "no_spec_change_rationale": "No canonical spec module governs this: .specsync/config.toml scans only source_dirs [src, templates], and this change touches only the repo's own CI/distribution surface (action.yml, its exercising workflow, and the README section describing them). No src/ or templates/ files change.", + "acceptance_criteria": [ + "A version input that is not 'latest' or a release tag is rejected before any network call, so a value containing '..' can no longer redirect the download to another repository; install-dir is rejected when it contains a '..' segment or a line break; the checksum sidecar is fetched with its HTTP status captured and any non-200 fails the step, so no input or network outcome installs an unverified binary; the unsupported-platform error states that the action does not support the platform yet and points at the Windows binary every release does publish; the latest lookup parses tag_name with jq and names the HTTP status on failure; the token is passed through a stdin curl config rather than argv; the temp directory is removed on every exit path; test-action.yml gains a refuses-unsafe-install job covering the traversal version and the sidecar-less v0.5.0 release, and its push trigger is narrowed to main so a branch push runs it once; README.md no longer claims a checksum guarantee the script does not deliver; fledge lanes run pre-commit is green." + ], + "selected_artifacts": [ + "context", + "testing", + "tasks", + "design", + "docs", + "requirements", + "research", + "plan" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "no" + } +} diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/tasks.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/tasks.md new file mode 100644 index 0000000..4347013 --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/tasks.md @@ -0,0 +1,50 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: tasks +--- + +# Tasks + +## Research before implementing + +- [x] Survey sidecar coverage across all 39 releases (decides remove-vs-narrow) +- [x] Reproduce the curl path-traversal against real github.com +- [x] Verify `-f` + `-w '%{http_code}'` and `--retry` vs `--retry-all-errors` semantics + +## Blocker 1 — input validation + +- [x] Allowlist `version` to `latest` or a release tag, before any use +- [x] Re-validate the tag resolved from the API +- [x] Reject `..` segments and line breaks in `install-dir` + +## Blocker 2 — checksum verification + +- [x] Capture `%{http_code}` on the sidecar fetch; fail on any non-200 +- [x] Report a 404 as "no sidecar published" rather than as a generic failure +- [x] Reject a sidecar body that is not a 64-hex-char digest +- [x] Drop `--retry-all-errors` from the sidecar fetch so a 404 fails fast +- [x] Correct README.md's checksum claim to match the implementation + +## Correctness — Windows + +- [x] Rewrite the unsupported-platform error: the action doesn't support it + *yet*; fledge does publish `fledge-windows-x86_64.exe` +- [x] Update README.md's platform sentence to match +- [x] Rename the regression job so its name doesn't imply the binary is missing + +## Nits + +- [x] Parse `tag_name` with `jq`; drop the `|| true` +- [x] Surface the HTTP status when the `latest` lookup fails +- [x] Move the Bearer token from argv to a stdin curl config file +- [x] `trap 'rm -rf "$tmp"' EXIT` +- [x] Narrow `on: push` to `main` so a branch push runs the workflow once + +## Verification + +- [x] `refuses-unsafe-install` job covering traversal + sidecar-less v0.5.0 +- [x] Pass step outcomes/outputs into assertion steps via `env:` +- [x] Re-run every branch of the extracted script locally (testing.md) +- [x] `shellcheck -s bash` clean; both YAML files parse +- [x] `fledge lanes run pre-commit` green +- [x] Refresh CHG-0008's stale evidence via audited `reopen → verify → accept` diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/testing.md b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/testing.md new file mode 100644 index 0000000..86700ce --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/testing.md @@ -0,0 +1,82 @@ +--- +change: CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers +artifact: testing +--- + +# Testing + +## Automated (`.github/workflows/test-action.yml`) + +- `install` matrix (`ubuntu-latest`/`macos-latest` × `v1.7.2`/`latest`, via + `uses: ./`): unchanged from CHG-0008 except that outputs now reach the + assertion step through `env:`. Guards the happy paths. +- `refuses-unsafe-install` (**new**): asserts that + `version: ../../../octocat/Hello-World/releases/download/v1` fails + (REQ-harden-action-1) and that `v0.5.0` fails rather than installing + unverified (REQ-harden-action-3). +- `windows-unsupported`: unchanged in intent, renamed to "windows is refused + with a readable message" so the job name no longer reads as "fledge has no + Windows binary" (REQ-harden-action-5). +- `on:` narrowed to `push` on `main` + `pull_request` on `main` + (REQ-harden-action-8). + +## Manual verification (local, macOS arm64, real `CorvidLabs/fledge` releases) + +The `run:` block was extracted byte-for-byte from `action.yml` via +`yaml.safe_load` and executed directly — not simulated — with `RUNNER_OS`, +`RUNNER_ARCH`, `GITHUB_PATH` and `GITHUB_OUTPUT` supplied, a fresh throwaway +`GITHUB_OUTPUT` per case. + +**Happy paths — still work after the hardening** + +1. Pinned `v1.7.2`: downloaded `fledge-macos-aarch64`, verified its real + sidecar, installed, `fledge --version` → `fledge 1.7.2`, + `outputs.version=v1.7.2`, `$GITHUB_PATH` written. exit 0. +2. `latest`, anonymous: resolved to `v1.7.2` via `jq`, installed. exit 0. +3. `latest`, real `gh auth token`: same. exit 0. + +**Refusals — each exits 1 with an `::error::` and writes nothing to +`$GITHUB_OUTPUT`** + +4. `version: ../../../octocat/Hello-World/releases/download/v1` → rejected + before any network call (REQ-1). +5. `version: "v1.7.2; echo pwned"` → rejected (REQ-1). +6. `install-dir` containing `../` → rejected (REQ-2). +7. `install-dir` containing a newline → rejected (REQ-2). Without the guard + this value would have appended a forged `version=` line to + `$GITHUB_OUTPUT`. +8. `version: v0.5.0` → sidecar fetch 404, install refused: "no sidecar is + published there — releases before v0.9.1 predate them" (REQ-3). +9. `RUNNER_OS=Windows` → refused before any release-asset request, message + names the platform, says "yet", and points at + `fledge-windows-x86_64.exe` (REQ-5). +10. `RUNNER_ARCH=RISCV` → refused, same shape. +11. `latest` + an invalid token → "the token was rejected or its rate limit is + exhausted (HTTP 401)". Also proves the Bearer header from the stdin config + file is genuinely sent: an anonymous request would have succeeded (REQ-6). + +**Checksum branches unreachable by input alone** — driven by `sed`-patching a +*copy* of the extracted script; the committed script was not modified: + +12. Mismatch (binary fetched from one asset, sidecar from another) → rejected, + both digests printed (REQ-3). +13. Malformed sidecar (sidecar URL pointed at a 200 response that is not a + digest) → rejected by the 64-hex-char guard (REQ-3). + +**Supporting checks** + +14. `shellcheck -s bash` on the extracted script: clean, exit 0. +15. `yaml.safe_load` on `action.yml` and `test-action.yml`: both parse. +16. Temp-dir leak check: stray `mktemp -d` count identical before and after + each failing run — the `trap ... EXIT` fires on the failure paths + (REQ-7). +17. curl semantics verified directly rather than assumed — see research.md + §3 (REQ-4, REQ-6). + +## Rejection signal + +If any input value or network outcome results in an installed binary whose +checksum was not verified; if a `version` containing `..` reaches a URL; if +the token appears in argv; if a failed `latest` lookup reports no HTTP status; +if a failing run leaves its temp directory behind; or if README.md again +promises a guarantee the script does not keep — the change is wrong. diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification-attempts.json b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification-attempts.json new file mode 100644 index 0000000..5c0aa4d --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification-attempts.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1786921694, + "commit": "a868dba40fe1d42dba62dd21bd8358aed1a57eb7", + "contract_digest": "3164717310eb979344b205f92b6244df36ce362d679f5f700bba1129971f43d5", + "workspace_digest": "49e8f338b9c2e32fc09c3918f0275fbe5a6b647feca65685cf9178526b28d83d", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification.json b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification.json new file mode 100644 index 0000000..6ecffdb --- /dev/null +++ b/.specsync/changes/CHG-0009-harden-the-setup-fledge-composite-action-after-pr-511-review-validate-the-vers/verification.json @@ -0,0 +1,61 @@ +{ + "timestamp": 1786921694, + "commit": "a868dba40fe1d42dba62dd21bd8358aed1a57eb7", + "contract_digest": "3164717310eb979344b205f92b6244df36ce362d679f5f700bba1129971f43d5", + "workspace_digest": "49e8f338b9c2e32fc09c3918f0275fbe5a6b647feca65685cf9178526b28d83d", + "acceptance_input_digest": "efb988265b4d08e86e9fbd220c3cc68e2a16ad69cc05363f70b7181ac5490e57", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".github/workflows/test-action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "5950570b82122806690a816c768369c1ae29b8acc4642cdc80a97a2b29d369f4", + "entry_digest": "425111ea3e1cfeaafb924945c6dbfdc348c55518b41fbae872cc3f89669a6293", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d11b740e2b3e12382b68849a5551ae2221dc84d81058ed4a81057a8749ae422", + "entry_digest": "f8c917c45ccf74f83c1c7eeeaf8929991d514d18ae5a4394c007cd5107dcce4c", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "5609822315d9f4b4e89f96928c6ee16b5d40e7b33573015b4c464f14f3661c1e", + "entry_digest": "cf4d107be5e03c03e0e332243ae42b854667cd0afc21bc154f1f035c46f53be1", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "action.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1ee83d2a24c4bc3e88a645e95da58a8c1a7c77c19acf57fc407f76dfd2a36fc4", + "entry_digest": "fcdc56208fb18ba639964f77d88de32900d75a9b50b6928279ad0b8053fd994a", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify-native", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/README.md b/README.md index 61c9456..05ca821 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,18 @@ git clone https://github.com/CorvidLabs/fledge.git && cd fledge && cargo install Pin `version` to a release tag and the action downloads that binary directly — no GitHub API call, so nothing to rate-limit on shared runner IPs. Leave it at the default `latest` and the action resolves the tag via an authenticated API -call (using `github.token` unless you pass your own `token`). Linux and macOS -runners (`x86_64`/`aarch64`) are supported; every download is checksum-verified -against the release's `.sha256` sidecar. +call (using `github.token` unless you pass your own `token`). `version` accepts +a release tag or `latest` and nothing else, since the value reaches a download +URL. + +Every download is verified against the release's `.sha256` sidecar, and a +checksum that is missing, unfetchable, or mismatched fails the step — the +install never degrades to an unverified binary. Releases before `v0.9.1` +predate the sidecars and so cannot be installed this way. + +Linux and macOS runners (`x86_64`/`aarch64`) are supported. Windows is not +supported by the action *yet*, though fledge does publish +`fledge-windows-x86_64.exe` in every release for direct download. ```yaml outputs: diff --git a/action.yml b/action.yml index 123ef63..3764f1e 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,8 @@ inputs: description: >- Release tag to install, such as v1.7.2, or "latest". Pin it in CI: a concrete tag downloads the release asset directly and makes no API call, - while "latest" has to ask the API which tag that is. + while "latest" has to ask the API which tag that is. Anything other than + a release tag or "latest" is rejected — the value reaches a download URL. required: false default: 'latest' token: @@ -46,13 +47,45 @@ runs: set -euo pipefail repo="CorvidLabs/fledge" + + valid_version() { + printf '%s' "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' + } + + # `version` ends up in a download URL, and curl resolves dot segments + # before sending, so a `..` in it escapes the repo namespace entirely — + # and whatever binary lands is then installed 0755 and executed. A + # consumer keying this off a branch name or PR title in a + # pull_request_target workflow is the ordinary way untrusted data gets + # here, so validate at the boundary instead of trusting every caller. + version="$INPUT_VERSION" + if [ "$version" != "latest" ] && ! valid_version "$version"; then + echo "::error::Invalid version '${version}'. Expected a release tag such as v1.7.2, or 'latest'." >&2 + exit 1 + fi + + # Same class of problem, lower severity: a `..` segment writes the + # binary outside the intended tree, and a newline injects extra lines + # into $GITHUB_PATH / $GITHUB_OUTPUT. install_dir="${INPUT_INSTALL_DIR:-$HOME/.local/bin}" + case "$install_dir" in + *$'\n'* | *$'\r'*) + echo "::error::install-dir must not contain line breaks." >&2 + exit 1 + ;; + esac + case "/${install_dir}/" in + */../*) + echo "::error::install-dir must not contain '..' path segments: ${install_dir}" >&2 + exit 1 + ;; + esac case "$RUNNER_OS" in Linux) os="linux" ;; macOS) os="macos" ;; *) - echo "::error::fledge publishes no binary for $RUNNER_OS via this action (Linux and macOS only). See https://github.com/${repo}/releases for the full asset list." >&2 + echo "::error::This action does not support ${RUNNER_OS} yet — it installs on Linux and macOS runners only. fledge itself does publish binaries for other platforms (fledge-windows-x86_64.exe among them); download one directly from https://github.com/${repo}/releases if you need it." >&2 exit 1 ;; esac @@ -66,21 +99,50 @@ runs: ;; esac - version="$INPUT_VERSION" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + if [ "$version" = "latest" ]; then # Authenticated when a token is available, because the anonymous - # limit is per IP and runners share them. - auth=() + # limit is per IP and runners share them. The token goes in through a + # stdin config file rather than argv so it never reaches the process + # table. + conf=( + "silent" + "show-error" + "location" + "fail" + "retry = 3" + 'header = "Accept: application/vnd.github+json"' + 'header = "X-GitHub-Api-Version: 2022-11-28"' + ) if [ -n "${INPUT_TOKEN:-}" ]; then - auth=(-H "Authorization: Bearer ${INPUT_TOKEN}") + conf+=("header = \"Authorization: Bearer ${INPUT_TOKEN}\"") fi - version="$(curl -fsSL --retry 3 --retry-all-errors "${auth[@]}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${repo}/releases/latest" \ - | grep -m1 '"tag_name"' | cut -d'"' -f4 || true)" - if [ -z "$version" ]; then - echo "::error::Could not resolve the latest fledge release. Pin \`version\` to a tag instead." >&2 + + set +e + status="$(printf '%s\n' "${conf[@]}" | curl --config - -w '%{http_code}' \ + -o "${tmp}/latest.json" \ + "https://api.github.com/repos/${repo}/releases/latest")" + rc=$? + set -e + + # Rate-limiting is the failure this action exists to remove, so name + # it rather than collapsing every cause into one opaque message. + if [ "$rc" -ne 0 ] || [ "$status" != "200" ]; then + case "$status" in + 401 | 403) detail="the token was rejected or its rate limit is exhausted (HTTP ${status})" ;; + 429) detail="rate-limited (HTTP 429)" ;; + 000) detail="the request never completed — network or DNS failure (curl exit ${rc})" ;; + *) detail="HTTP ${status}, curl exit ${rc}" ;; + esac + echo "::error::Could not resolve the latest fledge release: ${detail}. Pin \`version\` to a release tag instead — a pinned tag makes no API call at all." >&2 + exit 1 + fi + + version="$(jq -r '.tag_name // empty' < "${tmp}/latest.json")" + if [ -z "$version" ] || ! valid_version "$version"; then + echo "::error::The API returned no usable tag_name for the latest release (got '${version}'). Pin \`version\` to a release tag instead." >&2 exit 1 fi fi @@ -88,38 +150,56 @@ runs: asset="fledge-${os}-${arch}" base="https://github.com/${repo}/releases/download/${version}" - mkdir -p "$install_dir" - tmp="$(mktemp -d)" - if ! curl -fsSL --retry 3 --retry-all-errors "${base}/${asset}" -o "${tmp}/fledge"; then echo "::error::Failed to download ${asset} for ${version}. Check that the tag exists and publishes this asset: ${base}/${asset}" >&2 exit 1 fi - # Every release ships a .sha256 beside the binary; a corrupted download - # should fail here rather than at the first fledge invocation. - if curl -fsSL --retry 3 --retry-all-errors "${base}/${asset}.sha256" -o "${tmp}/fledge.sha256"; then - expected="$(cut -d' ' -f1 < "${tmp}/fledge.sha256")" - if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "${tmp}/fledge" | cut -d' ' -f1)" + # Verification is mandatory. Skipping it on a failed sidecar fetch + # would let anyone able to fail one request downgrade the install to an + # unverified one, and whoever can tamper with the binary can also block + # its sidecar. `--retry 3` without `--retry-all-errors` retries the + # transient statuses but not 404, so a genuinely absent sidecar fails + # fast instead of burning the backoff. + set +e + sum_status="$(curl -fsSL --retry 3 -w '%{http_code}' \ + -o "${tmp}/fledge.sha256" "${base}/${asset}.sha256")" + sum_rc=$? + set -e + + if [ "$sum_rc" -ne 0 ] || [ "$sum_status" != "200" ]; then + if [ "$sum_status" = "404" ]; then + detail="no sidecar is published there — releases before v0.9.1 predate them" else - actual="$(shasum -a 256 "${tmp}/fledge" | cut -d' ' -f1)" - fi - if [ "$expected" != "$actual" ]; then - echo "::error::Checksum mismatch for ${asset}: expected ${expected}, got ${actual}." >&2 - exit 1 + detail="HTTP ${sum_status}, curl exit ${sum_rc}" fi + echo "::error::Could not fetch the checksum for ${asset} at ${version} (${detail}): ${base}/${asset}.sha256. Refusing to install an unverified binary." >&2 + exit 1 + fi + + expected="$(cut -d' ' -f1 < "${tmp}/fledge.sha256")" + if ! printf '%s' "$expected" | grep -Eq '^[0-9a-fA-F]{64}$'; then + echo "::error::Malformed checksum sidecar for ${asset} at ${version}; expected a sha256 digest." >&2 + exit 1 + fi + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "${tmp}/fledge" | cut -d' ' -f1)" else - echo "::warning::No checksum published for ${asset}; skipping verification. This release predates checksum sidecars." + actual="$(shasum -a 256 "${tmp}/fledge" | cut -d' ' -f1)" + fi + if [ "$expected" != "$actual" ]; then + echo "::error::Checksum mismatch for ${asset} at ${version}: expected ${expected}, got ${actual}." >&2 + exit 1 fi + mkdir -p "$install_dir" install -m 0755 "${tmp}/fledge" "${install_dir}/fledge" - rm -rf "$tmp" echo "$install_dir" >> "$GITHUB_PATH" echo "version=${version}" >> "$GITHUB_OUTPUT" echo "path=${install_dir}/fledge" >> "$GITHUB_OUTPUT" - echo "Installed fledge ${version} to ${install_dir}/fledge" + echo "Installed fledge ${version} to ${install_dir}/fledge (sha256 verified)" - shell: bash run: fledge --version