From 86ab79b919717d875c9c981e01ae3d66062f4ee0 Mon Sep 17 00:00:00 2001 From: Wesley Ferreira Date: Wed, 14 Oct 2026 21:20:24 -0300 Subject: [PATCH 1/3] fix(AKO-182): verify release manifests and rebuild frozen source drafts safely --- .github/workflows/gh-akf-new-release.yaml | 82 +++++++++++++++------ README.md | 20 ++++++ cmd/release-assets-check/checksum.go | 41 +++++++++++ cmd/release-assets-check/main.go | 86 +++++++++++++++++++++++ cmd/release-assets-check/main_test.go | 72 +++++++++++++++++++ 5 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 cmd/release-assets-check/checksum.go create mode 100644 cmd/release-assets-check/main.go create mode 100644 cmd/release-assets-check/main_test.go diff --git a/.github/workflows/gh-akf-new-release.yaml b/.github/workflows/gh-akf-new-release.yaml index 75401350..18e22d28 100644 --- a/.github/workflows/gh-akf-new-release.yaml +++ b/.github/workflows/gh-akf-new-release.yaml @@ -1,6 +1,13 @@ name: Publish GitHub release on: + workflow_dispatch: + inputs: + release_tag: + description: Existing frozen source tag to package; never moves a tag. + required: true + type: string + default: v1.0.10 push: tags: - "v*.*.*" @@ -8,8 +15,11 @@ on: permissions: contents: write +env: + AKOFLOW_RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ inputs.release_tag || github.ref_name }} cancel-in-progress: true jobs: @@ -17,8 +27,17 @@ jobs: name: Test daemon runs-on: ubuntu-latest steps: + - name: Validate frozen release tag + shell: bash + run: | + if ! [[ "$AKOFLOW_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.+][0-9A-Za-z.-]+)?$ ]]; then + echo "A semantic version source tag is required." >&2 + exit 1 + fi - name: Checkout source uses: actions/checkout@v6 + with: + ref: refs/tags/${{ env.AKOFLOW_RELEASE_TAG }} - name: Set up Go uses: actions/setup-go@v6 @@ -46,6 +65,8 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v6 + with: + ref: refs/tags/${{ env.AKOFLOW_RELEASE_TAG }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -57,10 +78,10 @@ jobs: file: releases/dockerfiles/server.Dockerfile platforms: ${{ matrix.platform }} push: false - tags: akoflow/daemon:${{ github.ref_name }} - outputs: type=docker,dest=/tmp/akoflow-daemon-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar + tags: akoflow/daemon:${{ env.AKOFLOW_RELEASE_TAG }} + outputs: type=docker,dest=/tmp/akoflow-daemon-${{ env.AKOFLOW_RELEASE_TAG }}-linux-${{ matrix.arch }}.tar build-args: | - AKOFLOW_VERSION=${{ github.ref_name }} + AKOFLOW_VERSION=${{ env.AKOFLOW_RELEASE_TAG }} cache-from: type=gha,scope=runtime-daemon-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=runtime-daemon-${{ matrix.arch }} @@ -71,10 +92,10 @@ jobs: file: releases/dockerfiles/buildkit.Dockerfile platforms: ${{ matrix.platform }} push: false - tags: akoflow/buildkit:${{ github.ref_name }} - outputs: type=docker,dest=/tmp/akoflow-buildkit-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar + tags: akoflow/buildkit:${{ env.AKOFLOW_RELEASE_TAG }} + outputs: type=docker,dest=/tmp/akoflow-buildkit-${{ env.AKOFLOW_RELEASE_TAG }}-linux-${{ matrix.arch }}.tar build-args: | - AKOFLOW_VERSION=${{ github.ref_name }} + AKOFLOW_VERSION=${{ env.AKOFLOW_RELEASE_TAG }} cache-from: type=gha,scope=runtime-buildkit-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=runtime-buildkit-${{ matrix.arch }} @@ -84,18 +105,18 @@ jobs: set -euo pipefail cd /tmp sha256sum \ - "akoflow-daemon-${GITHUB_REF_NAME}-linux-${{ matrix.arch }}.tar" \ - "akoflow-buildkit-${GITHUB_REF_NAME}-linux-${{ matrix.arch }}.tar" \ - > "akoflow-runtime-${GITHUB_REF_NAME}-linux-${{ matrix.arch }}.sha256" + "akoflow-daemon-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.tar" \ + "akoflow-buildkit-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.tar" \ + > "akoflow-runtime-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.sha256" - name: Upload runtime archives uses: actions/upload-artifact@v5 with: name: runtime-${{ matrix.arch }} path: | - /tmp/akoflow-daemon-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar - /tmp/akoflow-buildkit-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar - /tmp/akoflow-runtime-${{ github.ref_name }}-linux-${{ matrix.arch }}.sha256 + /tmp/akoflow-daemon-${{ env.AKOFLOW_RELEASE_TAG }}-linux-${{ matrix.arch }}.tar + /tmp/akoflow-buildkit-${{ env.AKOFLOW_RELEASE_TAG }}-linux-${{ matrix.arch }}.tar + /tmp/akoflow-runtime-${{ env.AKOFLOW_RELEASE_TAG }}-linux-${{ matrix.arch }}.sha256 if-no-files-found: error desktop: @@ -114,7 +135,7 @@ jobs: # fail while producing the universal macOS package. signing: none - runner: windows-latest - command: npx electron-builder --win --x64 --publish never + command: npx electron-builder --win --x64 --publish never --config.nsis.artifactName='Akoflow-Desktop-${version}-win-${arch}-setup.${ext}' --config.portable.artifactName='Akoflow-Desktop-${version}-win-${arch}-portable.${ext}' artifact: windows-x64 signing: windows - runner: ubuntu-latest @@ -127,12 +148,24 @@ jobs: uses: actions/checkout@v6 with: repository: UFFeScience/akoflow-desktop - # Desktop releases intentionally package the current frontend main. - ref: main + # Application source is immutable; packaging overrides are explicit. + ref: refs/tags/${{ env.AKOFLOW_RELEASE_TAG }} # GITHUB_TOKEN is scoped to this repository and cannot read the # private desktop repository. Use a fine-grained cross-repo token. token: ${{ secrets.DESKTOP_REPOSITORY_TOKEN }} + - name: Checkout release verification tools + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + path: .release-tools + + - name: Set up Go verification tools + uses: actions/setup-go@v6 + with: + go-version-file: .release-tools/go.mod + cache: false + - name: Set up Node.js uses: actions/setup-node@v6 with: @@ -144,9 +177,9 @@ jobs: id: version shell: bash run: | - version="${GITHUB_REF_NAME#v}" + version="${AKOFLOW_RELEASE_TAG#v}" if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.+][0-9A-Za-z.-]+)?$ ]]; then - echo "Release tag must be a semantic version prefixed with v; received: $GITHUB_REF_NAME" >&2 + echo "Release tag must be a semantic version prefixed with v." >&2 exit 1 fi echo "value=$version" >> "$GITHUB_OUTPUT" @@ -179,6 +212,11 @@ jobs: fi done + - name: Verify updater manifests against distributed files + working-directory: .release-tools + shell: bash + run: go run ./cmd/release-assets-check --dir "$GITHUB_WORKSPACE/release" --version "$AKOFLOW_RELEASE_TAG" + - name: Upload desktop package uses: actions/upload-artifact@v5 with: @@ -194,7 +232,7 @@ jobs: if-no-files-found: error release: - name: Create GitHub release + name: Create verified GitHub release draft needs: [runtime-assets, desktop] runs-on: ubuntu-latest steps: @@ -234,8 +272,10 @@ jobs: - name: Create AkôFlow release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ env.AKOFLOW_RELEASE_TAG }} + draft: true generate_release_notes: true - prerelease: ${{ contains(github.ref_name, '-') }} + prerelease: ${{ contains(env.AKOFLOW_RELEASE_TAG, '-') }} files: release-assets/* body: | ## Install AkôFlow Desktop @@ -246,4 +286,4 @@ jobs: - **Windows:** run the x64 portable `.exe` asset without an installation wizard. - **Linux:** run the x64 `.AppImage` after `chmod +x`, or install the `.deb` with `sudo apt install ./Akoflow-Desktop-*.deb`. - The matching source is the Git tag `${{ github.ref_name }}`. The runtime Docker archives in this release are loaded locally by Desktop; AkôFlow does not publish daemon or BuildKit packages to a container registry. + The application source is the frozen Git tag `${{ env.AKOFLOW_RELEASE_TAG }}`. Windows packaging uses distinct `setup` and `portable` filenames and all updater SHA512 manifests are verified before upload. The runtime Docker archives in this release are loaded locally by Desktop; AkôFlow does not publish daemon or BuildKit packages to a container registry. This draft must be reviewed before publication. diff --git a/README.md b/README.md index 43dfe5b1..d832a077 100755 --- a/README.md +++ b/README.md @@ -55,6 +55,26 @@ workflows, and distributed computing. AkôFlow contributor avatars +## Maintainer release packaging + +Create the matching version tag in the Desktop repository before triggering the +backend release workflow. Application and runtime builds check out the frozen +source tag; packaging fixes can rebuild an existing draft without moving tags: + +```sh +gh workflow run gh-akf-new-release.yaml \ + --repo UFFeScience/akoflow --ref main \ + --field release_tag=v1.0.10 +``` + +Windows NSIS and portable executables use distinct `setup` and `portable` +filenames. Before upload, the Go `release-assets-check` command verifies updater +manifest versions, local filenames, sizes, SHA-512 values and legacy metadata +against the actual packages. The workflow creates a draft, not a public release. +Review successful checks, runtime checksums, package contents and screenshot +provenance before publishing the draft. Do not move an existing source tag to +hide a packaging correction. + ## Cite AkôFlow If AkôFlow contributes to your research, please cite: diff --git a/cmd/release-assets-check/checksum.go b/cmd/release-assets-check/checksum.go new file mode 100644 index 00000000..3f0f4f51 --- /dev/null +++ b/cmd/release-assets-check/checksum.go @@ -0,0 +1,41 @@ +package main + +import ( + "bytes" + "crypto/sha512" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func verifyFile(directory string, artifact releaseFile) error { + if artifact.URL == "" || artifact.URL == "." || filepath.Base(artifact.URL) != artifact.URL || strings.ContainsAny(artifact.URL, "/\\") { + return fmt.Errorf("artifact must be a local basename: %q", artifact.URL) + } + want, err := base64.StdEncoding.DecodeString(artifact.SHA512) + if err != nil || len(want) != sha512.Size { + return fmt.Errorf("invalid SHA512 for %q", artifact.URL) + } + path := filepath.Join(directory, artifact.URL) + stat, err := os.Lstat(path) + if err != nil || !stat.Mode().IsRegular() { + return fmt.Errorf("artifact must exist as a regular non-symlink file: %q", artifact.URL) + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + hash := sha512.New() + size, err := io.Copy(hash, file) + if err != nil { + return err + } + if size <= 0 || (artifact.Size != nil && size != *artifact.Size) || !bytes.Equal(want, hash.Sum(nil)) { + return fmt.Errorf("distributed artifact does not match updater size/SHA512: %q", artifact.URL) + } + return nil +} diff --git a/cmd/release-assets-check/main.go b/cmd/release-assets-check/main.go new file mode 100644 index 00000000..fc6fd2a9 --- /dev/null +++ b/cmd/release-assets-check/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type releaseManifest struct { + Version string `yaml:"version"` + Files []releaseFile `yaml:"files"` + Path string `yaml:"path"` + SHA512 string `yaml:"sha512"` +} + +type releaseFile struct { + URL string `yaml:"url"` + SHA512 string `yaml:"sha512"` + Size *int64 `yaml:"size"` +} + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, output io.Writer) error { + flags := flag.NewFlagSet("release-assets-check", flag.ContinueOnError) + directory := flags.String("dir", "", "Directory containing packages and updater manifests") + version := flags.String("version", "", "Expected release version") + if err := flags.Parse(args); err != nil { + return err + } + want := strings.TrimPrefix(*version, "v") + if *directory == "" || want == "" || flags.NArg() != 0 { + return fmt.Errorf("--dir and --version are required; positional arguments are not allowed") + } + manifests, err := filepath.Glob(filepath.Join(*directory, "latest*.yml")) + if err != nil || len(manifests) == 0 { + return fmt.Errorf("updater manifests are required in %q: %v", *directory, err) + } + for _, path := range manifests { + if err := verifyManifest(path, *directory, want); err != nil { + return err + } + fmt.Fprintln(output, "Verified updater manifest:", filepath.Base(path)) + } + return nil +} + +func verifyManifest(path, directory, version string) error { + encoded, err := os.ReadFile(path) + if err != nil { + return err + } + var manifest releaseManifest + if err := yaml.Unmarshal(encoded, &manifest); err != nil { + return fmt.Errorf("decode updater manifest: %w", err) + } + if manifest.Version != version || len(manifest.Files) == 0 { + return fmt.Errorf("invalid manifest version/files in %q", filepath.Base(path)) + } + if manifest.Path != "" || manifest.SHA512 != "" { + if manifest.Path != manifest.Files[0].URL || manifest.SHA512 != manifest.Files[0].SHA512 { + return fmt.Errorf("legacy updater path/SHA512 disagree with files in %q", filepath.Base(path)) + } + } + seen := make(map[string]bool, len(manifest.Files)) + for _, file := range manifest.Files { + if seen[file.URL] || !strings.Contains(file.URL, version) { + return fmt.Errorf("duplicate or wrong-version artifact %q", file.URL) + } + seen[file.URL] = true + if err := verifyFile(directory, file); err != nil { + return err + } + } + return nil +} diff --git a/cmd/release-assets-check/main_test.go b/cmd/release-assets-check/main_test.go new file mode 100644 index 00000000..cb1b60b7 --- /dev/null +++ b/cmd/release-assets-check/main_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "crypto/sha512" + "encoding/base64" + "io" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestManifestDetectsCollisionCorruptionAndWrongVersion(t *testing.T) { + for _, scenario := range []string{"valid", "overwritten", "size", "version", "duplicate", "missing", "empty", "legacy-hash", "legacy-path"} { + t.Run(scenario, func(t *testing.T) { + directory := t.TempDir() + payload := []byte("installer") + digest := sha512.Sum512(payload) + size := int64(len(payload)) + entry := releaseFile{ + URL: "Desktop-1.0.10-setup.exe", + SHA512: base64.StdEncoding.EncodeToString(digest[:]), + Size: &size, + } + manifest := releaseManifest{Version: "1.0.10", Files: []releaseFile{entry}} + switch scenario { + case "overwritten": + payload = []byte("portable!") + case "size": + size++ + case "version": + manifest.Version = "1.0.9" + case "duplicate": + manifest.Files = append(manifest.Files, entry) + case "empty": + manifest.Files = nil + case "legacy-hash": + manifest.Path, manifest.SHA512 = entry.URL, "different" + case "legacy-path": + manifest.Path, manifest.SHA512 = "different.exe", entry.SHA512 + } + if scenario != "missing" { + if err := os.WriteFile(filepath.Join(directory, entry.URL), payload, 0600); err != nil { + t.Fatal(err) + } + } + encoded, err := yaml.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "latest.yml"), encoded, 0600); err != nil { + t.Fatal(err) + } + err = run([]string{"--dir", directory, "--version", "v1.0.10"}, io.Discard) + if (err == nil) != (scenario == "valid") { + t.Fatalf("scenario=%s err=%v", scenario, err) + } + }) + } +} + +func TestArtifactPathsAndChecksumsFailClosed(t *testing.T) { + for _, name := range []string{"../outside.exe", "nested/file.exe", `nested\file.exe`, ".", ""} { + if verifyFile(t.TempDir(), releaseFile{URL: name}) == nil { + t.Fatalf("unsafe path accepted: %q", name) + } + } + if run([]string{"--dir", t.TempDir(), "--version", "1.0.10"}, io.Discard) == nil { + t.Fatal("missing manifests accepted") + } +} From b8fcfdcc1ae8f13c2dd5b9811558081c4ae44620 Mon Sep 17 00:00:00 2001 From: Wesley Ferreira Date: Wed, 14 Oct 2026 21:22:15 -0300 Subject: [PATCH 2/3] fix(AKO-182): exclude internal packaging debug reports from releases --- .github/workflows/gh-akf-new-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gh-akf-new-release.yaml b/.github/workflows/gh-akf-new-release.yaml index 18e22d28..5de9351c 100644 --- a/.github/workflows/gh-akf-new-release.yaml +++ b/.github/workflows/gh-akf-new-release.yaml @@ -227,7 +227,7 @@ jobs: release/*.exe release/*.AppImage release/*.deb - release/*.yml + release/latest*.yml release/*.blockmap if-no-files-found: error From 7973f7e4d7cb878d23145263e934d13ad21c6e78 Mon Sep 17 00:00:00 2001 From: Wesley Ferreira Date: Wed, 14 Oct 2026 21:25:09 -0300 Subject: [PATCH 3/3] fix(AKO-182): reuse only proven frozen-source runtime archives --- .github/workflows/gh-akf-new-release.yaml | 47 +++++++++++++++++++++++ README.md | 7 ++++ 2 files changed, 54 insertions(+) diff --git a/.github/workflows/gh-akf-new-release.yaml b/.github/workflows/gh-akf-new-release.yaml index 5de9351c..42f5f7a4 100644 --- a/.github/workflows/gh-akf-new-release.yaml +++ b/.github/workflows/gh-akf-new-release.yaml @@ -8,12 +8,17 @@ on: required: true type: string default: v1.0.10 + runtime_artifact_run: + description: Optional completed tagged-push run whose successful runtime archives can be reused. + required: false + type: string push: tags: - "v*.*.*" permissions: contents: write + actions: read env: AKOFLOW_RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} @@ -68,10 +73,44 @@ jobs: with: ref: refs/tags/${{ env.AKOFLOW_RELEASE_TAG }} + - name: Verify runtime artifact provenance + id: reuse + if: inputs.runtime_artifact_run != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + AKOFLOW_RUNTIME_RUN: ${{ inputs.runtime_artifact_run }} + AKOFLOW_RUNTIME_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + if ! [[ "$AKOFLOW_RUNTIME_RUN" =~ ^[0-9]+$ ]]; then + echo "Runtime artifact run must be numeric." >&2 + exit 1 + fi + source_sha=$(git rev-parse HEAD) + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$AKOFLOW_RUNTIME_RUN" | + jq --exit-status --arg sha "$source_sha" --arg repo "$GITHUB_REPOSITORY" --arg tag "$AKOFLOW_RELEASE_TAG" \ + '.head_sha == $sha and .status == "completed" and .event == "push" and .head_branch == $tag and .head_repository.full_name == $repo' >/dev/null + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$AKOFLOW_RUNTIME_RUN/jobs" | + jq --exit-status --arg name "Build runtime archives ($AKOFLOW_RUNTIME_ARCH)" \ + '[.jobs[] | select(.name == $name and .conclusion == "success")] | length == 1' >/dev/null + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Download verified runtime archives + if: steps.reuse.outputs.enabled == 'true' + uses: actions/download-artifact@v5 + with: + name: runtime-${{ matrix.arch }} + run-id: ${{ inputs.runtime_artifact_run }} + github-token: ${{ github.token }} + path: /tmp + - name: Set up Docker Buildx + if: steps.reuse.outputs.enabled != 'true' uses: docker/setup-buildx-action@v3 - name: Build daemon archive + if: steps.reuse.outputs.enabled != 'true' uses: docker/build-push-action@v6 with: context: . @@ -86,6 +125,7 @@ jobs: cache-to: type=gha,mode=max,scope=runtime-daemon-${{ matrix.arch }} - name: Build BuildKit archive + if: steps.reuse.outputs.enabled != 'true' uses: docker/build-push-action@v6 with: context: . @@ -100,6 +140,7 @@ jobs: cache-to: type=gha,mode=max,scope=runtime-buildkit-${{ matrix.arch }} - name: Create runtime checksums + if: steps.reuse.outputs.enabled != 'true' shell: bash run: | set -euo pipefail @@ -109,6 +150,12 @@ jobs: "akoflow-buildkit-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.tar" \ > "akoflow-runtime-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.sha256" + - name: Verify runtime archive checksums + shell: bash + run: | + cd /tmp + sha256sum --check "akoflow-runtime-${AKOFLOW_RELEASE_TAG}-linux-${{ matrix.arch }}.sha256" + - name: Upload runtime archives uses: actions/upload-artifact@v5 with: diff --git a/README.md b/README.md index d832a077..844c8607 100755 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ gh workflow run gh-akf-new-release.yaml \ --field release_tag=v1.0.10 ``` +The optional `runtime_artifact_run` input can reuse successful runtime archives +from a completed tagged-push run only when its repository, tag, source SHA and +architecture job all match. Downloaded archives are checked against their +SHA-256 manifests before upload. Desktop packages are always rebuilt and their +updater metadata verified; a failed publication does not justify reusing an +unverified installer. + Windows NSIS and portable executables use distinct `setup` and `portable` filenames. Before upload, the Go `release-assets-check` command verifies updater manifest versions, local filenames, sizes, SHA-512 values and legacy metadata