Skip to content
Open
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
50 changes: 50 additions & 0 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Daily package set planning

on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: package-set-version-check
cancel-in-progress: false

jobs:
version-check:
runs-on: ubuntu-latest
# Planning performs at most 96 incremental compile probes, then a submitted
# server job is polled for at most 60 minutes. Leave enough headroom that a
# difficult plan can still emit its report and reconcile trustee issues.
timeout-minutes: 180

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@v3

- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false

- name: Plan, verify, and submit pending package set updates
env:
GITHUB_TOKEN: ${{ github.token }}
run: nix run .#version-check -- --verify --submit --reconcile-issues --output version-check.json --markdown version-check.md

- name: Upload report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: package-set-version-check
path: |
version-check.json
version-check.md
if-no-files-found: warn
retention-days: 14
37 changes: 19 additions & 18 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,12 +779,13 @@ Anyone can suggest a package set update to the registry. However, community memb

A package set update must be submitted to the registry via GitHub issues on the registry repository. The body of the issue should contain the JSON object specified below, optionally within a code fence.

A package set update is an object with two keys: `compiler`, an optional field that, if set, will update the compiler version used to compile the package sets (it cannot be downgraded), and `packages`, an object where keys are package names and values are either a version number or `null`. A version number indicates the package should be added to the set or updated to the given version, and `null` indicates the package should be dropped from the package set.
A package set update is an object with three keys: `expectedBaseline`, an optional package set version that the update was planned and verified against; `compiler`, an optional field that, if set, will update the compiler version used to compile the package sets (it cannot be downgraded); and `packages`, an object where keys are package names and values are either a version number or `null`. A version number indicates the package should be added to the set or updated to the given version, and `null` indicates the package should be dropped from the package set. If `expectedBaseline` is present and is not the latest package set version when the operation executes, then the operation is rejected before validation, compilation, or publication. The field may be omitted for compatibility with manually submitted updates, which apply to the latest package set available when they execute.

```jsonc
{ // Sets the package set compiler version to 0.15.2
"expectedBaseline": "2.1.1",
"compiler": "0.15.2",
"packages" {
"packages": {
// Updates the `aff` package to v8.0.0
"aff": "8.0.0",
// Removes the `argonaut` package from the package sets altogether
Expand Down Expand Up @@ -812,13 +813,15 @@ The suggested new package set can only be released if:
1. All packages in the set depend only on other packages in the set
2. All packages in the set can be compiled together

Package set self-containment concerns dependency names, not the version ranges in package manifests. Every dependency name must occur in the package set, but its manifest range need not include the exact version selected by the set. Compiling the exact package versions in the set with the set's exact compiler version is the authoritative validity check.

To verify the package set, we take the following steps:

1. We apply the suggested changes to the package set and verify that the package set is still self-contained. If not, the update is rejected.
1. We apply all suggested changes to the package set as one exact batch and verify that the package set is still self-contained. If not, the update is rejected.
2. We install the previous package set and compile it. This ensures we are beginning from a known good state.
3. We install the new package set (uninstalling any packages that are removed) and compile the package set again. If compilation fails then the update is rejected.

When we have verified the package set is self-contained and all packages compile together then we can release the package set.
When we have verified the package set is self-contained and all packages compile together then we can release the package set. A submitted package set update is atomic: either its entire exact payload is released or it fails without releasing a subset.

##### Releasing the Package Set

Expand Down Expand Up @@ -874,27 +877,25 @@ If documentation publishing fails (for example, due to a transient network error

#### 6.3 Publish to Package Sets

The registry attempts to produce a new package set automatically every day, so long as packages have been uploaded that could be added or updated. No packages are ever dropped from a package set automatically; the only time packages are dropped from the package sets are during manual releases.
The registry attempts to produce a new package set automatically every day when eligible package versions exist. The latest package set and its compiler version are the starting point for every automatic plan.

Every day, the registry executes the following steps:
Newer registry versions of packages already in the package set remain candidates until they are included or superseded by the package set itself. They do not cease to be candidates merely because an automatic run could not include them or because time has passed since publication. An automatic plan may coordinate upgrades to several packages and may select an intermediate newer version instead of only the latest release when that produces a valid set.

First, we read the contents of the latest package set release and gather all package versions that have been uploaded to the registry since that release. These package versions are the "batch" of packages that we are considering for automatic inclusion to the next package set.
Packages that have never appeared in a package set are eligible only while they are recent additions. Their candidate set is recursively expanded with any dependency names absent from the current package set so the planner can consider a self-contained addition.

Second, we filter out any packages where, based on their metadata and manifest files alone, we know they can't be added to the package set. This happens for one of three reasons:
Every automatic plan must satisfy these constraints:

1. They have a dependency that is neither in the package sets nor in the batch that is up for consideration
2. They have had multiple releases since the last package set, in which case we only take the highest version
3. They already have a higher version published in a previous package set
1. It does not remove a package.
2. It does not downgrade a package.
3. It does not change the compiler version.
4. Every dependency name in the resulting set occurs in that set. Manifest version ranges are ignored for this self-containment check.
5. The exact resulting package set compiles as a whole with the unchanged, exact compiler version.

Third, we attempt to add the rest of the batch of package versions to the package set. Processing the batch follows these steps:
The whole-set compiler result is authoritative: manifest version ranges do not prevent an exact selection from being a valid package set, and manifest compatibility alone does not prove that a selection is valid. Each selected plan is submitted and released as one exact atomic package set update according to [Section 5.5 (Update the Package Set)](#55-update-the-package-set).

1. We install the previous package set and compile it.
2. We attempt to upgrade all package versions from the batch at once in the package set. Once the new versions are installed, we compile the package set. If it succeeds, then we're done.
3. If we couldn't compile the whole batch, then we order the batch first by their dependencies and then by their upload time. Packages with no dependencies on other packages in the batch go first, and ties are broken by upload time: older uploads go first. Then, we attempt to add packages to the package set one-by-one.
4. If a package fails to compile with the rest of the package set, then it is filtered from the batch.
5. Once there are no more packages to consider in the batch, the new package set is ready.
If an otherwise desirable upgrade requires removing packages, it is not an automatic plan. Registry Trustees handle such cases using the manual intervention process in [Section 9.5 (Package Sets)](#manual-intervention-in-the-package-sets-via-the-package-sets-api).

Fourth, we release the new package set (if we could produce one). Automatic package sets follow the versioning policy described in [Section 5.5 (Update the Package Set)](#releasing-the-package-set).
The automation reports residual upgrades it could not apply, including compiler evidence. It may report an exact removal-containing payload as compile-verified only after compiling that full payload against its original package set baseline. Such payloads are advisory and are never submitted automatically; a Registry Trustee must decide whether to apply them. Infrastructure failures and bounded searches must be reported distinctly from package incompatibility.

## 7. Non-JavaScript Backends

Expand Down
30 changes: 20 additions & 10 deletions app-e2e/src/Test/E2E/Scripts.purs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import Registry.App.Effect.Cache as Cache
import Registry.App.Effect.Env as Env
import Registry.App.Effect.GitHub as GitHub
import Registry.App.Effect.Log as Log
import Registry.App.Effect.PackageSets as PackageSets
import Registry.App.Effect.Registry as Registry
import Registry.App.Effect.Storage as Storage
import Registry.Foreign.FSExtra as FS.Extra
import Registry.Foreign.Octokit as Octokit
import Registry.Location (Location(..))
import Registry.Operation (AuthenticatedPackageOperation(..))
Expand Down Expand Up @@ -140,19 +143,20 @@ spec = do
jobs <- Client.getJobs
let packageSetJobs = Array.filter isPackageSetJob jobs
case Array.head packageSetJobs of
Just (PackageSetJob { payload }) ->
case payload of
Operation.PackageSetUpdate { packages } ->
case Map.lookup typeEqualityName packages of
Just (Just _) -> pure unit
_ -> Assert.fail "Expected type-equality in package set update"
Just (PackageSetJob { payload }) -> do
let { packages } = Operation.packageSetUpdateData payload
case Map.lookup typeEqualityName packages of
Just (Just _) -> pure unit
_ -> Assert.fail "Expected type-equality in package set update"
Just _ -> Assert.fail "Expected PackageSetJob but got different job type"
Nothing -> Assert.fail "Expected package set job to be enqueued"

-- | Common environment for running read-only registry scripts in E2E tests
type RegistryScriptSetup =
{ resourceEnv :: Env.ResourceEnv
{ cache :: FilePath
, resourceEnv :: Env.ResourceEnv
, registryEnv :: Registry.RegistryEnv
, workdir :: FilePath
}

type GitHubScriptSetup =
Expand All @@ -171,18 +175,21 @@ setupRegistryScript = do
resourceEnv <- liftEffect Env.lookupResourceEnv
registryCacheRef <- liftAff Cache.newCacheRef
debouncer <- liftAff Registry.newDebouncer
let workdir = Path.concat [ stateDir, "scratch" ]
let cache = Path.concat [ workdir, ".cache" ]
liftAff $ FS.Extra.ensureDirectory cache
let
registryEnv :: Registry.RegistryEnv
registryEnv =
{ jobId: Nothing
, pull: Git.Autostash
, write: Registry.ReadOnly
, repos: Registry.defaultRepos
, workdir: Path.concat [ stateDir, "scratch" ]
, workdir
, debouncer
, cacheRef: registryCacheRef
}
pure { resourceEnv, registryEnv }
pure { cache, resourceEnv, registryEnv, workdir }

setupGitHubScript :: E2E GitHubScriptSetup
setupGitHubScript = do
Expand Down Expand Up @@ -229,12 +236,15 @@ runPackageTransferrerScript = do
-- | Run the PackageSetUpdater script in Submit mode
runPackageSetUpdaterScript :: E2E Unit
runPackageSetUpdaterScript = do
{ resourceEnv, registryEnv } <- setupRegistryScript
{ cache, resourceEnv, registryEnv, workdir } <- setupRegistryScript
result <- liftAff
$ PackageSetUpdater.runPackageSetUpdater PackageSetUpdater.Submit resourceEnv.registryApiUrl
# PackageSets.interpret (PackageSets.handle { workdir })
# Except.runExcept
# Registry.interpretRead (Registry.handleRead registryEnv)
# Storage.interpret (Storage.handleReadOnly cache)
# Log.interpret (Log.handleTerminal Quiet)
# Env.runResourceEnv resourceEnv
# Run.runBaseAff'
case result of
Left err -> liftAff $ Aff.throwError $ Aff.error $ "PackageSetUpdater failed: " <> err
Expand Down
32 changes: 18 additions & 14 deletions app/src/App/API.purs
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,10 @@ type PackageSetUpdateEffects r = (REGISTRY + PACKAGE_SETS + LOG + EXCEPT String
-- | been verified at the API boundary, so we don't need to check team membership.
packageSetUpdate :: forall r. PackageSetJobData -> Run (PackageSetUpdateEffects + r) Unit
packageSetUpdate details = do
let Operation.PackageSetUpdate payload = details.payload
let payload = Operation.packageSetUpdateData details.payload
let expectedBaseline = Operation.packageSetUpdateExpectedBaseline details.payload

Log.debug $ "Package set update job starting with payload:\n" <> stringifyJson Operation.packageSetUpdateCodec payload
Log.debug $ "Package set update job starting with payload:\n" <> stringifyJson Operation.packageSetOperationCodec details.payload

latestPackageSet <- Registry.readLatestPackageSet >>= case _ of
Nothing -> do
Expand All @@ -162,9 +163,17 @@ packageSetUpdate details = do
Just set -> pure set

Log.debug $ "Most recent package set was " <> stringifyJson PackageSet.codec latestPackageSet
let latestVersion = (un PackageSet latestPackageSet).version
let prevCompiler = (un PackageSet latestPackageSet).compiler
let prevPackages = (un PackageSet latestPackageSet).packages

for_ expectedBaseline \expected -> when (expected /= latestVersion) do
Except.throw $ Array.fold
[ "Package set update expected baseline " <> Version.print expected
, ", but the latest package set is " <> Version.print latestVersion
, ". Refusing to apply the update to a different baseline."
]

-- Note: authentication for restricted operations (compiler change, package removal)
-- is handled at the API boundary in the Router, not here.

Expand Down Expand Up @@ -225,18 +234,13 @@ packageSetUpdate details = do

let changeSet = candidates.accepted <#> maybe Remove Update
Log.notice "Attempting to build package set update."
PackageSets.upgradeSequential latestPackageSet (fromMaybe prevCompiler payload.compiler) changeSet >>= case _ of
Nothing ->
Except.throw "No packages could be added to the package set. All packages failed to compile."
Just { failed, succeeded, result: packageSet } -> do
unless (Map.isEmpty failed) do
let
formatFailed = String.joinWith "\n" $ Array.catMaybes $ flip map (Map.toUnfoldable failed) \(Tuple name change) ->
case change of
PackageSets.Update version -> Just $ " - " <> formatPackageVersion name version
PackageSets.Remove -> Nothing
Log.warn $ "Some packages could not be added to the set:\n" <> formatFailed
let commitMessage = PackageSets.commitMessage latestPackageSet succeeded (un PackageSet packageSet).version
-- A submitted payload is exact. Sequential probing is useful for planning,
-- but must not turn this operation into a partial release.
PackageSets.upgradeAtomic latestPackageSet (fromMaybe prevCompiler payload.compiler) changeSet >>= case _ of
Left error ->
Except.throw $ "Package set update failed to compile atomically. No changes were published.\n\n" <> error
Right packageSet -> do
let commitMessage = PackageSets.commitMessage latestPackageSet changeSet (un PackageSet packageSet).version
Registry.writePackageSet packageSet commitMessage
Log.notice "Built and released a new package set! Now mirroring to the package-sets repo..."
Registry.mirrorPackageSet packageSet
Expand Down
15 changes: 7 additions & 8 deletions app/src/App/GitHubIssue.purs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import Registry.Foreign.JsonRepair as JsonRepair
import Registry.Foreign.Octokit (GitHubToken, IssueNumber(..), Octokit)
import Registry.Foreign.Octokit as Octokit
import Registry.Internal.Format as Internal.Format
import Registry.Operation (AuthenticatedData, AuthenticatedPackageOperation(..), PackageOperation(..), PackageSetOperation(..))
import Registry.Operation (AuthenticatedData, AuthenticatedPackageOperation(..), PackageOperation(..), PackageSetOperation)
import Registry.Operation as Operation
import Run (AFF, EFFECT, Run)
import Run as Run
Expand Down Expand Up @@ -86,9 +86,9 @@ runGitHubIssue env = do
run do
-- Determine endpoint and prepare the JSON payload
{ endpoint, jsonBody } <- case env.operation of
Left packageSetOp@(PackageSetUpdate payload) -> do
Left packageSetOp -> do
-- Sign with pacchettibotti if submitter is a trustee
request <- signPackageSetIfTrustee packageSetOp payload
request <- signPackageSetIfTrustee packageSetOp
pure
{ endpoint: "/v1/package-sets"
, jsonBody: JSON.print $ CJ.encode Operation.packageSetUpdateRequestCodec request
Expand Down Expand Up @@ -366,7 +366,7 @@ readOperation eventPath = do
let keys = CJ.Object.keys object
let hasKeys = all (flip Array.elem keys)
if hasKeys [ "packages" ] then
map (Left <<< PackageSetUpdate) (CJ.decode Operation.packageSetUpdateCodec json)
map Left (CJ.decode Operation.packageSetOperationCodec json)
else if hasKeys [ "name", "ref", "version" ] then
map (Right <<< Publish) (CJ.decode Operation.publishCodec json)
else if hasKeys [ "payload", "signature" ] then
Expand All @@ -378,7 +378,7 @@ readOperation eventPath = do
, "Received keys: " <> String.joinWith ", " keys
, ""
, "Expected one of:"
, " - Package set update: { packages, compiler? }"
, " - Package set update: { packages, compiler?, expectedBaseline? }"
, " - Publish: { name, ref, compiler?, version, location?, resolutions? }"
, " - Authenticated (unpublish/transfer): { payload, signature }"
]
Expand Down Expand Up @@ -473,10 +473,9 @@ signPacchettiBottiIfTrustee auth = do
signPackageSetIfTrustee
:: forall r
. PackageSetOperation
-> Operation.PackageSetUpdateData
-> Run (GITHUB + PACCHETTIBOTTI_ENV + GITHUB_EVENT_ENV + LOG + EXCEPT String + r) Operation.PackageSetUpdateRequest
signPackageSetIfTrustee packageSetOp payload = do
let rawPayload = JSON.print $ CJ.encode Operation.packageSetUpdateCodec payload
signPackageSetIfTrustee packageSetOp = do
let rawPayload = JSON.print $ CJ.encode Operation.packageSetOperationCodec packageSetOp
GitHub.listTeamMembers API.packagingTeam >>= case _ of
Left githubError -> do
Log.warn $ Array.fold
Expand Down
3 changes: 1 addition & 2 deletions app/src/App/Server/Router.purs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import Registry.App.Effect.Db as Db
import Registry.App.Effect.Env as Env
import Registry.App.Effect.Log as Log
import Registry.App.Server.Env (ServerEffects, ServerEnv, jsonCreated, jsonDecoder, jsonOk, runEffects)
import Registry.Operation (PackageSetOperation(..))
import Registry.Operation as Operation
import Run (Run)
import Run as Run
Expand Down Expand Up @@ -165,7 +164,7 @@ router { route, method, body } = HTTPurple.usingCont case route, method of

-- Check if the operation requires authentication (compiler change or package removal)
let
PackageSetUpdate payload = request.payload
payload = Operation.packageSetUpdateData request.payload
didChangeCompiler = isJust payload.compiler
didRemovePackages = any isNothing payload.packages
requiresAuth = didChangeCompiler || didRemovePackages
Expand Down
Loading