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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ferrata/setup
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ if [ -f mise.toml ]; then
fi

# Install Entire CLI (yes to skip prompts in non-interactive sessions)
yes | curl -fsSL https://entire.io/install.sh | bash
yes | curl -fsSL https://entire.com/install.sh | bash
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ jobs:
GO_TEST_PARALLEL: "16"
run: mise run test:ci:core

# The installer reads our own release origin first and falls back to GitHub.
# A fallback that only runs during an incident is a fallback nobody knows
# works, so both tiers are exercised here against local fake origins.
test-release-origins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: jdx/mise-action@f10502fc09dadecfefb962fff68ce77213930204 # v4
- name: Release origin tests
run: mise run test:release-origins

test-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
Expand Down Expand Up @@ -117,6 +130,7 @@ jobs:
permissions: {}
needs:
- test-core
- test-release-origins
- test-integration
- test-codex-hooks
- test-canary
Expand All @@ -127,6 +141,7 @@ jobs:
- name: Check dependencies
run: |
[ "${{ needs.test-core.result }}" = "success" ]
[ "${{ needs.test-release-origins.result }}" = "success" ]
[ "${{ needs.test-integration.result }}" = "success" ]
[ "${{ needs.test-codex-hooks.result }}" = "success" ]
[ "${{ needs.test-canary.result }}" = "success" ]
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/nightly-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
done
}

retry 'curl -fsSL --compressed https://entire.io/install.sh | bash -s -- --channel nightly'
retry 'curl -fsSL --compressed https://entire.com/install.sh | bash -s -- --channel nightly'
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Install nightly CLI (macOS)
Expand Down Expand Up @@ -147,7 +147,7 @@ jobs:
}
}

Invoke-WithRetry { iex "& {$(irm https://entire.io/install.ps1)} -Channel nightly" }
Invoke-WithRetry { iex "& {$(irm https://entire.com/install.ps1)} -Channel nightly" }
"$HOME\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

# Pin the checkout to the tag that produced the installed binary, so the
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,27 @@ on:
permissions:
contents: write

# Deliberately NOT serialized with a concurrency group. GitHub keeps at most one
# pending run per group and cancels the previously pending one, which
# cancel-in-progress: false does not prevent — it only protects the run already
# in progress. mirror-nightly pushes a nightly tag, so a single stable release
# can enqueue a second run and cancel a queued hotfix by itself. Dropping a
# release is worse than the race a group would close, and the pointer's
# conditional write (--if-match in publish-channel-manifest.sh) already closes
# that race, including for runs started by hand.

env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
# Mint the OIDC token that configure-aws-credentials exchanges for
# short-lived credentials to the release-mirror bucket. Scoped to this
# job so the other jobs keep the workflow-level permissions.
id-token: write
outputs:
prerelease: ${{ steps.release-type.outputs.prerelease }}
steps:
Expand Down Expand Up @@ -95,6 +110,17 @@ jobs:
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi

# The release mirror is opt-in: with ENTIRE_RELEASE_BUCKET unset, this and
# the two steps below are skipped and the release publishes exactly as it
# did before. Setting the repository variables turns the mirror on with no
# code change. See docs/architecture/release-origins.md.
- name: Configure AWS credentials for the release mirror
if: vars.ENTIRE_RELEASE_BUCKET != ''
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ vars.ENTIRE_RELEASE_UPLOAD_ROLE }}
aws-region: ${{ vars.ENTIRE_RELEASE_REGION || 'us-east-2' }}

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
Expand All @@ -113,6 +139,8 @@ jobs:
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_ENDPOINT: ${{ vars.POSTHOG_ENDPOINT }}
ENTIRE_RELEASE_BUCKET: ${{ vars.ENTIRE_RELEASE_BUCKET }}
ENTIRE_RELEASE_REGION: ${{ vars.ENTIRE_RELEASE_REGION }}
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}

Expand Down Expand Up @@ -144,6 +172,37 @@ jobs:
done
exit $fail

# Last, and deliberately so. The pointer is what makes a version visible
# to install.sh and to the CLI's update check, so it must not move until
# the artifacts are uploaded AND the macOS signing check above has passed.
# Advertising a release whose binaries Gatekeeper kills would be worse
# than not advertising it at all.
- name: Publish channel pointer
if: vars.ENTIRE_RELEASE_BUCKET != ''
run: |
scripts/publish-channel-manifest.sh \
--tag "$RELEASE_TAG" \
--bucket "${{ vars.ENTIRE_RELEASE_BUCKET }}" \
--region "${{ vars.ENTIRE_RELEASE_REGION || 'us-east-2' }}"

# After the pointer, and stable-only. These keys (the two installers and
# the Scoop manifest) are overwritten on every release rather than
# versioned, so a nightly publishing here would replace the installer
# stable users fetch; nightly users are served by the same stable
# installer, which takes --channel nightly.
#
# After the pointer rather than before it: these keys are shared across
# versions, so uploading first means a release the pointer then refuses
# (an older tag re-dispatched) leaves the installers rolled back with
# nothing advanced. The installer is version-independent, so a pointer
# that moved before it is harmless.
- name: Publish install scripts and Scoop manifest
if: vars.ENTIRE_RELEASE_BUCKET != '' && steps.release-type.outputs.prerelease == 'false'
run: |
scripts/publish-installers.sh \
--bucket "${{ vars.ENTIRE_RELEASE_BUCKET }}" \
--region "${{ vars.ENTIRE_RELEASE_REGION || 'us-east-2' }}"

# A stable tag only refreshes the stable cask; the nightly channel would
# otherwise not catch up until the next scheduled nightly run cuts a tag from
# a *newer* commit (and create-nightly-tag.sh skips when HEAD already has a
Expand Down
56 changes: 56 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ builds:
goarch:
- amd64
- arm64
# Pin the binary mtime to the commit so rebuilding a tag produces identical
# bytes. Without it the archives carry build-time mtimes, so a retried
# release re-uploads different bytes — and different checksums — to
# v<version>/ URLs that are cached immutable for a year. An edge holding the
# old archive beside the new checksums.txt makes install.sh abort, because a
# checksum mismatch deliberately does not fall back.
mod_timestamp: "{{ .CommitTimestamp }}"
ldflags:
- -s -w
- -X github.com/entireio/cli/cmd/entire/cli/versioninfo.Version={{.Version}}
Expand Down Expand Up @@ -49,6 +56,13 @@ builds:
goarch:
- amd64
- arm64
# Pin the binary mtime to the commit so rebuilding a tag produces identical
# bytes. Without it the archives carry build-time mtimes, so a retried
# release re-uploads different bytes — and different checksums — to
# v<version>/ URLs that are cached immutable for a year. An edge holding the
# old archive beside the new checksums.txt makes install.sh abort, because a
# checksum mismatch deliberately does not fall back.
mod_timestamp: "{{ .CommitTimestamp }}"
ldflags:
- -s -w
- -X github.com/entireio/cli/cmd/entire/cli/versioninfo.Version={{.Version}}
Expand Down Expand Up @@ -102,6 +116,48 @@ archives:
checksum:
name_template: "checksums.txt"

# Mirror every release artifact to the release origin we own, so installing and
# updating the CLI keep working while GitHub is down. install.sh and the CLI's
# own update check read this origin first and fall back to GitHub, so the path
# is exercised by every install rather than only during an incident.
#
# Layout is <bucket>/v<version>/<file>, deliberately the same shape as GitHub's
# releases/download/v<version>/<file> — that symmetry is what lets install.sh
# build both origins' URLs from one template.
#
# Inert until ENTIRE_RELEASE_BUCKET is set, so this config can ship before the
# bucket exists; setting the repository variable is the only step that turns it
# on. Until then a release publishes exactly as it does today.
#
# Still on GitHub after this: the download URLs inside the Homebrew cask and the
# Scoop manifest (below). Those are only worth repointing at this origin once it
# is live and verified — a published manifest must never name a host that isn't
# serving yet. See docs/architecture/release-origins.md.
blobs:
- provider: s3
# Tests non-emptiness, NOT isEnvSet alone. release.yml always exports
# ENTIRE_RELEASE_BUCKET, and an Actions variable that is unset exports as
# an empty string — so isEnvSet is true on every release. Gating on
# presence would enable this pipe with an empty bucket and no credentials
# (configure-aws-credentials is correctly skipped), failing every release
# instead of staying inert.
#
# The isEnvSet half is kept rather than testing emptiness alone: GoReleaser
# is not consistent about missing env keys — build ldflags fail with "map
# has no entry for key", while other templates render empty — so the guard
# does not rely on which engine this field uses. Verified by rendering the
# gate with the variable absent, present-but-empty, and set.
disable: '{{ if and (isEnvSet "ENTIRE_RELEASE_BUCKET") (ne .Env.ENTIRE_RELEASE_BUCKET "") }}false{{ else }}true{{ end }}'
bucket: '{{ if and (isEnvSet "ENTIRE_RELEASE_BUCKET") (ne .Env.ENTIRE_RELEASE_BUCKET "") }}{{ .Env.ENTIRE_RELEASE_BUCKET }}{{ else }}unset{{ end }}'
region: '{{ if and (isEnvSet "ENTIRE_RELEASE_REGION") (ne .Env.ENTIRE_RELEASE_REGION "") }}{{ .Env.ENTIRE_RELEASE_REGION }}{{ else }}us-east-2{{ end }}'
directory: "v{{ .Version }}"
# A file under a version prefix never changes once published, so it can be
# cached for as long as anything is willing to hold it. The channel
# pointers, which do change, are uploaded separately with a short TTL by
# scripts/publish-channel-manifest.sh.
cache_control:
- "public, max-age=31536000, immutable"

release:
prerelease: auto

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ at session start. Follow its related links when the task crosses those boundarie
| External commands or agents | [External commands](docs/architecture/external-commands.md), [agent protocol](docs/architecture/external-agent-protocol.md) |
| Security, privacy, scanners | [Security and privacy](docs/security-and-privacy.md), [implementation contracts](docs/development/checkpoint-implementation.md) |
| Review command | [Review architecture](docs/architecture/review-command.md) |
| Release artifacts, install scripts, version-check origins | [Release origins](docs/architecture/release-origins.md) |
| E2E tests | [E2E guide](e2e/README.md) |

## Verification
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,18 @@ Use the fully-qualified cask name (`entireio/tap/entire`, not `entire`). Homebre
Or with the install script:

```bash
curl -fsSL https://entire.io/install.sh | bash # stable
# curl -fsSL https://entire.io/install.sh | bash -s -- --channel nightly # or nightly
curl -fsSL https://entire.com/install.sh | bash # stable
# curl -fsSL https://entire.com/install.sh | bash -s -- --channel nightly # or nightly
```

### Windows

Install with Windows PowerShell 5.1 or later:

```powershell
irm https://entire.io/install.ps1 | iex # stable
irm https://entire.com/install.ps1 | iex # stable
# or nightly:
# iex "& {$(irm https://entire.io/install.ps1)} -Channel nightly"
# iex "& {$(irm https://entire.com/install.ps1)} -Channel nightly"
```

For stable releases, the PowerShell installer uses Scoop when it is available,
Expand Down Expand Up @@ -138,12 +138,29 @@ How to use each channel:

- Homebrew stable: `brew install --cask entireio/tap/entire`
- Homebrew nightly: `brew install --cask entireio/tap/entire@nightly`
- `install.sh` stable: `curl -fsSL https://entire.io/install.sh | bash`
- `install.sh` nightly: `curl -fsSL https://entire.io/install.sh | bash -s -- --channel nightly`
- `install.ps1` stable (uses Scoop when available): `irm https://entire.io/install.ps1 | iex`
- `install.ps1` nightly: `iex "& {$(irm https://entire.io/install.ps1)} -Channel nightly"`
- `install.sh` stable: `curl -fsSL https://entire.com/install.sh | bash`
- `install.sh` nightly: `curl -fsSL https://entire.com/install.sh | bash -s -- --channel nightly`
- `install.ps1` stable (uses Scoop when available): `irm https://entire.com/install.ps1 | iex`
- `install.ps1` nightly: `iex "& {$(irm https://entire.com/install.ps1)} -Channel nightly"`
- Scoop: currently supports `stable` only via `scoop install entire/entire`

### If GitHub is down

Release artifacts are published to `install.entire.com` as well as to GitHub, and
`install.sh` reads that origin first — so the normal install command keeps
working through a GitHub outage. If you need to bypass version discovery
entirely, pin a version or fetch the archive directly:

```bash
curl -fsSL https://entire.com/install.sh | bash -s -- --version 0.10.0
curl -fsSL https://install.entire.com/v0.10.0/entire_darwin_arm64.tar.gz | tar -xz
```

Homebrew and Scoop still resolve their manifests from GitHub, so use one of the
commands above (or `go install github.com/entireio/cli/cmd/entire@v0.10.0`, which
is served from the Go module proxy) while GitHub is unavailable. See
[Release Origins](docs/architecture/release-origins.md).

## Typical Workflow

### 1. Enable Entire in Your Repository
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/versioncheck/autoupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func autoInstallers() []installerCase {
return []installerCase{
{name: "brew", execPath: brewCaskPath, wantCmd: brewUpgradeCmd},
{name: "mise", execPath: miseExecutablePath, wantCmd: miseUpgradeCmd},
{name: "unknown_curl_bash", execPath: plainBinPath, wantCmd: "curl -fsSL https://entire.io/install.sh | bash"},
{name: "unknown_curl_bash", execPath: plainBinPath, wantCmd: "curl -fsSL https://entire.com/install.sh | bash"},
}
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/versioncheck/autoupdate_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ func TestMaybeAutoUpdate_WindowsNeverAutoRuns(t *testing.T) {
name: "unknown stable prints install.ps1",
execPath: windowsProgramFilesPath,
currentVersion: "1.0.0",
wantCmd: `iex "& {$(irm https://entire.io/install.ps1)} -InstallDir 'C:\Program Files\Entire' -NoPathUpdate"`,
wantCmd: `iex "& {$(irm https://entire.com/install.ps1)} -InstallDir 'C:\Program Files\Entire' -NoPathUpdate"`,
},
{
name: "unknown nightly prints install.ps1 -Channel nightly",
execPath: windowsLocalBinPath,
currentVersion: "1.0.1-nightly.202604101200.abc1234",
wantCmd: `iex "& {$(irm https://entire.io/install.ps1)} -Channel nightly -InstallDir 'C:\Users\test\.local\bin' -NoPathUpdate"`,
wantCmd: `iex "& {$(irm https://entire.com/install.ps1)} -Channel nightly -InstallDir 'C:\Users\test\.local\bin' -NoPathUpdate"`,
},
}

Expand Down
39 changes: 38 additions & 1 deletion cmd/entire/cli/versioncheck/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ type GitHubRelease struct {
Prerelease bool `json:"prerelease"`
}

// ChannelManifest is the per-channel pointer file published to the release
// origin at release time (channels/stable.json, channels/nightly.json). It
// answers exactly one question — "what is the current version on this
// channel" — and deliberately carries no checksums: checksums.txt, which
// GoReleaser generates and mirrors alongside the archives, stays the single
// integrity source. Two places listing the same hashes is two places that
// can disagree.
type ChannelManifest struct {
Channel string `json:"channel"`
Tag string `json:"tag"`
Version string `json:"version"`
PublishedAt string `json:"published_at,omitempty"`
}

// channelBaseURL is the release origin we own, and the first candidate in the
// version-lookup chain. Reading it before api.github.com means the update
// check keeps working through a GitHub outage, and — because it is read on
// every check rather than only during an incident — the path cannot rot into
// a failover that has never actually run.
//
// This is a var (not const) to allow overriding in tests.
var channelBaseURL = "https://install.entire.com"

// githubAPIURL is the GitHub API endpoint for fetching the latest stable release.
// This is a var (not const) to allow overriding in tests.
var githubAPIURL = "https://api.github.com/repos/entireio/cli/releases/latest"
Expand All @@ -25,7 +48,21 @@ const (
// checkInterval is the duration between version checks.
checkInterval = 24 * time.Hour

// httpTimeout is the timeout for HTTP requests to the GitHub API.
// httpTimeout is the budget for the WHOLE origin chain, not per origin.
//
// It is an absolute number for the reason spelled out in
// checkpoint/remote/checkpoint_ref.go: a ceiling computed as
// origins × per-origin-timeout equals the loop's own worst case, so it
// can never bind. Keeping it at the historical 2s means adding a second
// origin costs no extra worst-case latency on this path, which runs
// synchronously after every command (see root.go's PersistentPostRun).
//
// fetchLatestForChannel divides this budget between the candidates rather
// than handing the whole of it to the first one, so a primary that hangs
// cannot starve the fallback. An earlier version of this comment claimed a
// hang "degrades to exactly today's behaviour"; it does not. Before this
// origin existed the check reached GitHub and worked, so a blackholed
// primary consuming the budget would have been a regression, not a no-op.
httpTimeout = 2 * time.Second

// cacheFileName is the name of the cache file stored in the global config directory.
Expand Down
Loading
Loading