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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/build-setup.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
- name: Require wrapper-authorized exact-main release tag
shell: bash
# Ruleset bypass actors are visible only with repository Administration read.
# This workflow is generated tag-only, so the release credential is never
# evaluated for pull requests or other untrusted refs.
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: ./scripts/verify-release-tag-authorization.sh
- name: Remove build-account paths from Rust artifacts
shell: bash
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ jobs:
test "$($dist_bin --version)" = 'cargo-dist 0.28.2'
echo "DIST_BIN=$dist_bin" >> "$GITHUB_ENV"
- run: '"$DIST_BIN" generate --check'
- name: Exercise release policy parser with pinned jq 1.6
shell: bash
run: |
jq_bin="$RUNNER_TEMP/jq-1.6"
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/jqlang/jq/releases/download/jq-1.6/jq-linux64 \
-o "$jq_bin"
echo 'af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 '"$jq_bin" | sha256sum --check
chmod +x "$jq_bin"
test "$($jq_bin --version)" = jq-1.6
REAL_JQ="$jq_bin" ./scripts/test-release-github-policy.sh
- run: ./scripts/test-release-authorization.sh
- name: Validate exact generated release plan and coherent activation state
shell: bash
Expand Down
25 changes: 18 additions & 7 deletions .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,36 @@ env:
CARGO_TERM_COLOR: always

jobs:
release-authorization:
name: protected release authorization
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.98.0
- name: Require wrapper-authorized exact-main release tag
shell: bash
# This job exists only for tag pushes. Manual package inspection and
# untrusted PR workflows have no path to this administration credential.
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: ./scripts/verify-release-tag-authorization.sh

release-version:
name: release topology and version
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.98.0
- name: Verify exact five-leg topology, pins, repository, tag, and wrapper authorization
- name: Verify exact five-leg topology, pins, repository, and version
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version="$(awk -F'"' '/^\[workspace\.package\]/{p=1;next} /^\[/{p=0} p&&/^version[[:space:]]*=/{print $2;exit}' Cargo.toml)"
test "$GITHUB_REF_TYPE" != tag || test "$GITHUB_REF_NAME" = "v$version"
repo="$(./scripts/validate-release-topology.sh)"
if test "$GITHUB_REF_TYPE" = tag; then
./scripts/verify-release-tag-authorization.sh
fi
cargo metadata --locked --no-deps --format-version 1 | jq -e --arg version "$version" --arg repo "https://github.com/$repo" '
([.packages[].name] | sort) == ["orchestratectl","taskfleet","taskfleet-core"] and
all(.packages[]; .version == $version and .repository == $repo) and
Expand Down Expand Up @@ -141,7 +152,7 @@ jobs:

publish-core:
name: crates.io / taskfleet-core
needs: [release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny]
needs: [release-authorization, release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny]
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 30
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ jobs:
run: "./scripts/verify-release-tag-authorization.sh"
shell: "bash"
env:
"GH_TOKEN": "${{ github.token }}"
"GH_TOKEN": "${{ secrets.HOMEBREW_TAP_TOKEN }}"
- name: "Remove build-account paths from Rust artifacts"
run: "printf 'RUSTFLAGS=%s\\n' \"${RUSTFLAGS:+$RUSTFLAGS }--remap-path-prefix=$GITHUB_WORKSPACE=/workspace --remap-path-prefix=$HOME=/build\" >> \"$GITHUB_ENV\""
shell: "bash"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Changed

### Fixed

- Make release-policy verification compatible with hosted jq 1.6 and supply its administration-readable credential only to tag-push authorization gates.
<!-- oss-changelog:unreleased-end -->

## [0.6.0] - 2026-09-06
Expand Down
24 changes: 24 additions & 0 deletions crates/taskfleet/tests/package_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ fn active_distribution_is_taskfleet_only_and_structurally_authorized() {
.count(),
1
);
assert_eq!(
workflow
.matches("\"GH_TOKEN\": \"${{ secrets.HOMEBREW_TAP_TOKEN }}\"")
.count(),
1
);
assert!(!workflow.contains("\"GH_TOKEN\": \"${{ github.token }}\""));

let publish = std::fs::read_to_string(root.join(".github/workflows/publish-crates.yml"))
.expect("crates release workflow");
assert!(!publish.contains("pull_request:"));
assert_eq!(
publish
.matches("GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}")
.count(),
1
);
assert!(publish.contains(
"release-authorization:\n name: protected release authorization\n if: ${{ github.event_name == 'push' }}"
));
assert!(publish.contains(
"needs: [release-authorization, release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny]"
));
assert!(!publish.contains("GH_TOKEN: ${{ github.token }}"));

let build_setup = std::fs::read_to_string(root.join(".github/build-setup.yml"))
.expect("cargo-dist build setup");
Expand Down
13 changes: 12 additions & 1 deletion issues/taskfleet-release-0-6-0/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ No cargo-dist release workflow ran for the PR; the exact-head workflow list cont

Two adversarial rounds and a context follow-up were assessed in `evidence/assessment.{json,md}`. Every confirmed in-scope release-safety or evidence gap was fixed. The retained constraints are explicit upstream/trust-boundary facts: cargo-dist 0.28.2 emits workflow-wide `contents: write`, its host tolerates skipped local jobs, and a repository administrator remains the policy authority. No review residual met the bar for a new issue.

## v0.6.0 publication failure and fix-forward

The conductor subsequently authorized and pushed immutable tag `v0.6.0` at `57f6dfb83401694399b363de5d3aa88e4541a22c` after exact-main CI run `34016341659`. Both independent publication workflows failed closed in the authorization gate before publication:

- crates workflow `34016740702`, gate job `101441707888`;
- cargo-dist workflow `34016740704`, build jobs `101441745244`, `101441745248`, and `101441745351`.

No crate, release asset, GitHub Release, or Homebrew formula was published. The tag and its authorization ref remain immutable and must not be reused.

The failures had two concrete causes. GitHub-hosted jq 1.6 rejected the filter variable `$include` because `include` is reserved. On the self-hosted macOS jq 1.8.2 runner the workflow `GITHUB_TOKEN` could read the public ruleset shape, but GitHub redacted `bypass_actors`; that field requires repository Administration read, which is not a grantable `GITHUB_TOKEN` permission. The focused fix-forward issue `@taskfleet-release-gate-ci-portability` changes the jq variable, provides the already SOPS-managed release credential only to push/tag authorization steps, adds non-secret diagnostics and fixtures, and targets a fresh v0.6.1 transaction. It does not retroactively make v0.6.0 published.

## Remaining conductor gate

After Taskfleet merges this branch, the conductor must wait for green push CI on the exact merged `main` SHA before invoking the Phase C wrapper. Phase C remains unchecked and no release coordinate is authorized by this document.
After the focused portability fix merges, the conductor must wait for green push CI on that exact merged `main` SHA and create a new sealed patch plan before invoking the wrapper for v0.6.1. Phase C remains unchecked for that new coordinate; this document does not authorize a release action or reuse of v0.6.0.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"schema_version": 1,
"checked_at": "2026-09-06T06:48:14Z",
"credential": "Homebase SOPS homebrew token (value not persisted)",
"repository": "jarimustonen/taskfleet",
"endpoints": [
"GET /repos/jarimustonen/taskfleet/rulesets/22234415",
"GET /repos/jarimustonen/taskfleet/rulesets/22234417"
],
"http_statuses": [
200,
200
],
"ruleset_ids": [
22234415,
22234417
],
"privileged_bypass_actors_visible": true,
"secret_value_logged": false
}
49 changes: 41 additions & 8 deletions issues/taskfleet-release-gate-ci-portability/item.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
created: 2026-09-06
updated: 2026-09-06
type: bug
status: open
status: fixed
priority: high
related: ['@taskfleet-release-0-6-0']
lane: taskfleet-rename
lane_seq: 111
collision: [scripts/verify-release-github-policy.sh, .github/workflows/release.yml, .github/workflows/publish-crates.yml]
closed: 2026-09-06
closed_by: pi
commits:
- hash: 8ad34689a4a23e3d354b37aafcf5099c0b6c448c
summary: fix release gate credentials and diagnostics
- hash: a8ab5d0e3475f83dbf116cbe71300316308b195f
summary: pin jq 1.6 fixture and pass exact candidate CI
---

# Release gate fails on CI jq and workflow token
Expand All @@ -24,6 +31,14 @@ The public v0.6.0 tag was authorized and pushed by the pinned release wrapper at
- cargo-dist workflow: `34016740704`, failed build jobs `101441745244`, `101441745248`, and `101441745351`.
- Shipshape journal: `01M1TNW3SMN0XA347D1MG4518R`.

## Root cause evidence

- The Linux failure is a jq parser incompatibility: jq 1.6 treats `include` as a reserved module keyword, so the filter variable `$include` never compiles. The production filter now uses `$ref_pattern` and is exercised unchanged in an Ubuntu 22.04 container reporting `jq-1.6`.
- `GET /repos/jarimustonen/taskfleet/rulesets/22234415` is publicly readable, but GitHub omits the privileged `bypass_actors` field from non-administrator responses. That is the shape returned with the workflow `GITHUB_TOKEN`; it is why jq 1.8 parsed the filter on the self-hosted macOS runner and then returned false without an API error.
- GitHub's ruleset endpoint requires repository **Administration: read** to return bypass actors. `GITHUB_TOKEN` has no grantable Administration permission, so changing workflow `contents` permissions cannot fix the redaction.
- A sanitized 2026-09-06 read using the Homebase SOPS-managed `HOMEBREW_TAP_TOKEN` returned HTTP 200 and exposed the required bypass-actor array for ruleset `22234415`; the token value was passed only through process environment and was neither printed nor persisted. See `credential-ruleset-read.json`.
- The generated cargo-dist workflow is tag-only and scopes that credential to its authorization step. The crates workflow scopes it to a dedicated `release-authorization` job guarded by `github.event_name == 'push'`; `publish-core` directly needs that job. Manual package inspection receives no credential, and neither publication workflow has a pull-request path to it.

## Required outcome

1. Keep v0.6.0 immutable and never retag or reuse it.
Expand All @@ -33,11 +48,29 @@ The public v0.6.0 tag was authorized and pushed by the pinned release wrapper at
5. Validate through an exact candidate PR and merged-main CI without tagging or publishing.
6. Document v0.6.0 as an unpublished burned tag and prepare a new patch release (v0.6.1) only through a newly sealed wrapper plan.

## Definition of Done
## Acceptance Criteria

- [x] Both authorization paths pass with the exact runner jq/tool/token topology used by tag workflows.
- [x] Missing, malformed, inaccessible, or mismatched rulesets still fail closed.
- [x] PRs cannot access release credentials or execute publication.
- [x] Full green gate and exact-SHA CI evidence are recorded.
- [x] No v0.6.0 artifact/package/formula was published and no tag was moved.
- [x] The fix is ready for a fresh v0.6.1 wrapper transaction.

## Validation

- Exact candidate: `a8ab5d0e3475f83dbf116cbe71300316308b195f`.
- Same-repository PR: [#3](https://github.com/jarimustonen/taskfleet/pull/3).
- Exact-SHA CI: [34018842931](https://github.com/jarimustonen/taskfleet/actions/runs/34018842931), green across hosted Linux/macOS, self-hosted ARM64 macOS, jq 1.6 release topology, MSRV, clippy, docs, deny, snapshots, and tests.
- The jq fixture passed against checksum-pinned `jq-1.6` (`af986793…a124c44`) and local jq 1.8.2. API failure, malformed shape, privilege-redacted shape, and policy mismatch fixtures each produced a distinct non-secret diagnostic and failed closed.
- Homebase's SOPS-managed release credential read both live rulesets with HTTP 200 and exposed `bypass_actors`; `credential-ruleset-read.json` contains only sanitized metadata.
- cargo-dist 0.28.2 `generate --check`, exact plan/topology validation, the pinned Homebrew 6.0.21 disposable distribution drill, actionlint structure, all release authorization/wrapper/publish fixtures, and the exact Shipshape 0.10.1 migration protocol passed.
- Full local gate passed: fmt, clippy warnings-as-errors, release nextest, doctests, and rustdoc warnings-as-errors. The all-workspace release nextest suite also passed with the stripped declared PATH.
- Postflight: all three crates remain without v0.6.0; no v0.6.0 GitHub Release exists; canonical and old tap heads remain `db12bb163e47617f0b941a35d3896b6ba0548892` and `85ce830378f38cf17283efddd966d5754354e403` respectively.
- No tag, authorization ref, ruleset, tap, registry, installation, or release journal was mutated by this fix.

## Resolution

### 2026-09-06T07:26:15Z · @pi

- [ ] Both authorization paths pass with the exact runner jq/tool/token topology used by tag workflows.
- [ ] Missing, malformed, inaccessible, or mismatched rulesets still fail closed.
- [ ] PRs cannot access release credentials or execute publication.
- [ ] Full green gate and exact-SHA CI evidence are recorded.
- [ ] No v0.6.0 artifact/package/formula was published and no tag was moved.
- [ ] The fix is ready for a fresh v0.6.1 wrapper transaction.
jq 1.6 portability, administration-readable tag-gate credentials, fail-closed diagnostics, exact cargo-dist generation, and candidate CI 34018842931 are verified; v0.6.0 remains unpublished and immutable.
25 changes: 25 additions & 0 deletions scripts/test-release-authorization.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
release="$repo_root/.github/workflows/release.yml"
publish="$repo_root/.github/workflows/publish-crates.yml"

# The production policy parser is a separate fixture so malformed/redacted API
# responses cannot be hidden by the tag-authorizer's stubbed policy boundary.
"$repo_root/scripts/test-release-github-policy.sh" >/dev/null

check_workflow() {
local workflow="$1"
Expand All @@ -13,6 +18,8 @@ check_workflow() {
if grep -F 'custom-taskfleet-release-gate' "$workflow" >/dev/null; then return 1; fi
[[ "$(grep -Fc 'name: "Require wrapper-authorized exact-main release tag"' "$workflow")" -ge 1 ]] || return 1
grep -F 'run: "./scripts/verify-release-tag-authorization.sh"' "$workflow" >/dev/null || return 1
[[ "$(grep -Fc '"GH_TOKEN": "${{ secrets.HOMEBREW_TAP_TOKEN }}"' "$workflow")" == 1 ]] || return 1
if grep -F '"GH_TOKEN": "${{ github.token }}"' "$workflow" >/dev/null; then return 1; fi
grep -A8 '^ build-local-artifacts:' "$workflow" | grep -F 'needs:' >/dev/null || return 1
grep -A12 '^ build-local-artifacts:' "$workflow" | grep -F 'needs.plan.outputs.publishing == '\''true'\''' >/dev/null || return 1
grep -A8 '^ build-global-artifacts:' "$workflow" | grep -F -- '- build-local-artifacts' >/dev/null || return 1
Expand All @@ -23,6 +30,24 @@ check_workflow() {
}
check_workflow "$release"

# crates.io keeps its credential in a dedicated push-only job.
# workflow_dispatch remains credential-free and can only build package archives.
if grep -A12 '^on:' "$publish" | grep -Eq 'pull_request:'; then
echo "PR-triggered crates workflow unexpectedly exposes a release gate" >&2; exit 1
fi
[[ "$(grep -Fc 'GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}' "$publish")" == 1 ]] || {
echo "crates tag gate does not receive the release credential exactly once" >&2; exit 1
}
grep -A4 '^ release-authorization:' "$publish" | grep -F 'if: ${{ github.event_name == '\''push'\'' }}' >/dev/null || {
echo "crates authorization job is not restricted to tag push events" >&2; exit 1
}
grep -A4 '^ publish-core:' "$publish" | grep -F 'release-authorization' >/dev/null || {
echo "crates publication does not depend on release authorization" >&2; exit 1
}
if grep -F 'GH_TOKEN: ${{ github.token }}' "$publish" >/dev/null; then
echo "crates tag gate still uses the redacted workflow token" >&2; exit 1
fi

# The authorization script itself is exercised, not merely grepped. Every
# independently mutable coordinate must fail closed.
tmp="$(mktemp -d "${TMPDIR:-/tmp}/taskfleet-release-auth.XXXXXX")"
Expand Down
69 changes: 69 additions & 0 deletions scripts/test-release-github-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Exercise the production ruleset filter and its fail-closed diagnostics.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/taskfleet-policy-fixture.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin"
jq_bin="${REAL_JQ:-$(command -v jq)}"
ln -s "$jq_bin" "$tmp/bin/jq"
ln -s "$(command -v mktemp)" "$tmp/bin/mktemp"
ln -s "$(command -v grep)" "$tmp/bin/grep"
ln -s "$(command -v tail)" "$tmp/bin/tail"
ln -s "$(command -v rm)" "$tmp/bin/rm"
cat >"$tmp/bin/gh" <<'STUB'
#!/bin/sh
id="${2##*/}"
case "${GH_FIXTURE_MODE:-valid}:$id" in
api-failure:*) echo 'gh: Resource not accessible by integration (HTTP 403)' >&2; exit 1 ;;
malformed:*) printf '%s\n' '{not-json'; exit 0 ;;
redacted:*)
jq -n --argjson id "$id" '{id:$id,name:"redacted",target:"tag",enforcement:"active",
conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}},rules:[]}' ;;
mismatch:*)
jq -n --argjson id "$id" '{id:$id,name:"wrong SECRET_FIXTURE_VALUE",target:"tag",enforcement:"active",
conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}},rules:[],bypass_actors:[]}' ;;
valid:22234415)
jq -n '{id:22234415,name:"Taskfleet release tags",target:"tag",enforcement:"active",
conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}},
rules:[{type:"creation"},{type:"update"},{type:"deletion"},{type:"non_fast_forward"}],
bypass_actors:[{actor_id:5,actor_type:"RepositoryRole",bypass_mode:"always"}]}' ;;
valid:22234417)
jq -n '{id:22234417,name:"Taskfleet release authorization refs",target:"branch",enforcement:"active",
conditions:{ref_name:{exclude:[],include:["refs/heads/taskfleet-release-authorizations/**"]}},
rules:[{type:"creation"},{type:"update"},{type:"deletion"},{type:"non_fast_forward"}],
bypass_actors:[{actor_id:5,actor_type:"RepositoryRole",bypass_mode:"always"}]}' ;;
*) exit 97 ;;
esac
STUB
chmod +x "$tmp/bin/gh"

run_policy() {
env -i PATH="$tmp/bin:/usr/bin:/bin" GH_FIXTURE_MODE="${1:-valid}" \
"$repo_root/scripts/verify-release-github-policy.sh"
}
run_policy valid >/dev/null

assert_failure() {
local mode="$1" expected="$2" output status
set +e
output="$(run_policy "$mode" 2>&1)"
status=$?
set -e
[[ "$status" -ne 0 ]] || { echo "$mode policy fixture unexpectedly passed" >&2; exit 1; }
grep -F "$expected" <<<"$output" >/dev/null || {
echo "$mode policy fixture lacked diagnostic: $expected" >&2
printf '%s\n' "$output" >&2
exit 1
}
if grep -F 'SECRET_FIXTURE_VALUE' <<<"$output" >/dev/null; then
echo "$mode policy fixture leaked API response content" >&2
exit 1
fi
}
assert_failure api-failure 'API lookup failed (HTTP 403)'
assert_failure malformed 'API returned an unexpected JSON shape'
assert_failure redacted 'API response omits privileged bypass_actors'
assert_failure mismatch 'does not match the required protected policy'

printf 'Taskfleet live release policy fixtures passed with %s\n' "$(jq --version)"
Loading
Loading