From 65362c6721ac3f8dbf4813ebffc5e8cc9bcb14d4 Mon Sep 17 00:00:00 2001 From: Kristian Larsson Date: Sun, 17 May 2026 13:13:01 +0200 Subject: [PATCH] Add optional OCI output artifacts Support optional Acton output artifacts fetched from OCI repositories during dependency resolution. Artifacts are keyed by source content hash, interface version, and target tuple. Use valid local cache entries before registry lookups and fall back to source when no matching artifact is available. Add artifact pack, push, and hash commands, local OCI layout support, tests, and guide documentation. --- compiler/acton/Main.hs | 23 +- compiler/acton/PkgCommands.hs | 159 ++++++++- compiler/acton/test.hs | 136 ++++++-- compiler/lib/package.yaml.in | 1 + compiler/lib/src/Acton/Artifact.hs | 255 ++++++++++++++ compiler/lib/src/Acton/CommandLineParser.hs | 46 ++- compiler/lib/src/Acton/Compile.hs | 355 +++++++++++++++++--- compiler/lib/test/ActonSpec.hs | 79 +++++ docs/acton-guide/src/SUMMARY.md | 1 + docs/acton-guide/src/output_artifacts.md | 157 +++++++++ docs/acton-guide/src/package_management.md | 22 +- 11 files changed, 1157 insertions(+), 77 deletions(-) create mode 100644 compiler/lib/src/Acton/Artifact.hs create mode 100644 docs/acton-guide/src/output_artifacts.md diff --git a/compiler/acton/Main.hs b/compiler/acton/Main.hs index 47de1c445..c74135f97 100644 --- a/compiler/acton/Main.hs +++ b/compiler/acton/Main.hs @@ -133,6 +133,7 @@ main = do C.CmdOpt gopts (C.PkgUpgrade opts) -> PkgCommands.pkgUpgradeCommand gopts opts C.CmdOpt gopts C.PkgUpdate -> PkgCommands.pkgUpdateCommand gopts C.CmdOpt gopts (C.PkgSearch opts) -> PkgCommands.pkgSearchCommand gopts opts + C.CmdOpt gopts (C.Artifact opts) -> artifactCommand gopts opts C.CmdOpt gopts (C.BuildSpecCmd o) -> buildSpecCommand o C.CmdOpt gopts (C.Cloud opts) -> undefined C.CmdOpt gopts (C.Doc opts) -> printDocs gopts opts @@ -511,6 +512,24 @@ withProjectLockForGen gopts sched gen projDir action = withProjectLockNotice gopts projDir $ whenCurrentGen sched gen action +artifactCommand :: C.GlobalOptions -> C.ArtifactCommand -> IO () +artifactCommand gopts cmd = + case cmd of + C.ArtifactHash _ -> + PkgCommands.artifactCommand gopts cmd + _ -> do + let opts = defaultCompileOptions { C.skip_build = True } + sp = Source.diskSourceProvider + paths <- loadProjectPaths opts + let projDir = projPath paths + withProjectLockNotice gopts projDir $ do + unless (C.quiet gopts) $ + putStrLn ("Building project in " ++ projDir) + srcFiles <- projectSourceFiles paths + compileFiles sp gopts opts srcFiles True + generateProjectDocIndex sp gopts opts paths srcFiles + PkgCommands.artifactCommand gopts cmd + requireProjectLayout :: Paths -> IO () requireProjectLayout paths = do exists <- doesDirectoryExist (srcDir paths) @@ -929,7 +948,7 @@ runWatchFile gopts absFile sched runOnce = do fetchCommand :: C.GlobalOptions -> IO () fetchCommand gopts = do paths <- loadProjectPaths defaultCompileOptions - res <- try (fetchDependencies gopts paths []) :: IO (Either ProjectError ()) + res <- try (fetchDependencies gopts paths [] []) :: IO (Either ProjectError ()) case res of Left (ProjectError msg) -> printErrorAndExit msg Right () -> @@ -955,7 +974,7 @@ sigCommand gopts sigOpts = do rootProj <- normalizePathSafe (projPath paths) sysAbs <- normalizePathSafe (sysPath paths) withProjectLockNotice queryGopts rootProj $ do - fetchDependencies queryGopts paths depOverrides + fetchDependencies queryGopts paths depOverrides (C.artifact_repos opts) projMap <- discoverProjects queryGopts sysAbs rootProj depOverrides target <- resolveSigTarget opts paths rootProj projMap (C.sigTarget sigOpts) tyFile <- case target of diff --git a/compiler/acton/PkgCommands.hs b/compiler/acton/PkgCommands.hs index c78fd70bb..ec8bc85a4 100644 --- a/compiler/acton/PkgCommands.hs +++ b/compiler/acton/PkgCommands.hs @@ -7,6 +7,7 @@ module PkgCommands , pkgUpgradeCommand , pkgUpdateCommand , pkgSearchCommand + , artifactCommand , zigPkgAddCommand , zigPkgRemoveCommand , PackageEntry(..) @@ -21,6 +22,7 @@ module PkgCommands import Prelude hiding (readFile, writeFile) +import qualified Acton.Artifact as Artifact import qualified Acton.BuildSpec as BuildSpec import qualified Acton.CommandLineParser as C import Acton.Compile (loadBuildSpec, throwProjectError) @@ -30,7 +32,7 @@ import Control.Concurrent (threadDelay) import Control.Monad (filterM, forM, forM_, unless, when) import Data.Char (isHexDigit, isSpace) import Data.Foldable (toList) -import Data.List (dropWhileEnd, isPrefixOf, isSuffixOf, sortOn) +import Data.List (dropWhileEnd, isPrefixOf, isSuffixOf, sort, sortOn) import Data.List.Split (splitOn) import Data.Maybe (isJust) import qualified Data.Map as M @@ -44,7 +46,7 @@ import Network.HTTP.Client (Manager, Response, httpLbs, parseRequest, requestHea import Network.HTTP.Client.TLS (newTlsManager) import Network.HTTP.Types.Header (Header) import Network.HTTP.Types.Status (statusCode) -import System.Directory (Permissions, canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist, getCurrentDirectory, getHomeDirectory, getPermissions, listDirectory, removeFile, setPermissions) +import System.Directory (Permissions, canonicalizePath, copyFile, copyFileWithMetadata, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist, getCurrentDirectory, getHomeDirectory, getPermissions, listDirectory, makeAbsolute, pathIsSymbolicLink, removeFile, setPermissions) import System.Environment (getExecutablePath, lookupEnv) import System.Exit (ExitCode(..)) import System.FilePath ((), takeDirectory) @@ -304,6 +306,159 @@ pkgSearchCommand _ opts = do then putStrLn "No packages matched your search." else forM_ (sortOn pkgName matched) printPkg +artifactCommand :: C.GlobalOptions -> C.ArtifactCommand -> IO () +artifactCommand gopts cmd = + case cmd of + C.ArtifactPack opts -> + packActonArtifact gopts (C.artifactPackOutput opts) + C.ArtifactPush opts -> + pushActonArtifact gopts opts + C.ArtifactHash opts -> + printActonArtifactHash (C.artifactHashSourcePath opts) + +packActonArtifact :: C.GlobalOptions -> String -> IO () +packActonArtifact gopts outputArg = do + sourceHash <- computeArtifactSourceHash "." + output <- packActonArtifactTo sourceHash outputArg + unless (C.quiet gopts) $ + putStrLn ("Wrote Acton artifact " ++ output) + +pushActonArtifact :: C.GlobalOptions -> C.ArtifactPushOptions -> IO () +pushActonArtifact gopts opts = do + sourceHash <- computeArtifactSourceHash "." + ref0 <- resolveArtifactRef sourceHash + (C.artifactPushRepoUrl opts) + (C.artifactPushArtifactRepo opts) + ref <- absolutizeLocalArtifactRef ref0 + withSystemTempDirectory "acton-artifact-push" $ \tmp -> do + let archive = tmp Artifact.artifactArchiveFile + _ <- packActonArtifactTo sourceHash archive + runProcessChecked (Just tmp) "oras" + (["push"] + ++ Artifact.ociRefOrasOptions ref + ++ [ "--artifact-type", Artifact.artifactType + , Artifact.ociRefOrasTarget ref + , Artifact.artifactArchiveFile ++ ":" ++ Artifact.artifactMediaType + ]) + unless (C.quiet gopts) $ + putStrLn ("Pushed Acton artifact " ++ ref) + +packActonArtifactTo :: String -> String -> IO FilePath +packActonArtifactTo sourceHash outputArg = do + cwd <- getCurrentDirectory + let output0 = if null outputArg then "out" Artifact.artifactArchiveFile else outputArg + createDirectoryIfMissing True (takeDirectory output0) + withSystemTempDirectory "acton-artifact-pack" $ \tmp -> do + Artifact.writeManifest tmp (Artifact.expectedManifest sourceHash) + runProcessChecked Nothing "tar" + [ "-czf", output0 + , "-C", tmp, Artifact.artifactManifestFile + , "-C", cwd, "Build.act", "out/types" + ] + return output0 + +printActonArtifactHash :: String -> IO () +printActonArtifactHash source = do + h <- computeArtifactSourceHash source + putStrLn h + +computeArtifactSourceHash :: FilePath -> IO String +computeArtifactSourceHash source = do + isDir <- doesDirectoryExist source + unless isDir $ + throwProjectError ("ERROR: Artifact source hash expects a package directory: " ++ source) + zigExe <- getZigExe + withSystemTempDirectory "acton-artifact-source" $ \tmp -> do + let staged = tmp "source" + -- TODO(source-hash): Avoid staging a copied package tree here. + -- + -- This copy is intentionally simple and conservative, but source hashing is + -- foundational identity machinery and should eventually be computed directly + -- from the package directory. The desired implementation is an Acton-owned + -- package hash walker that follows Zig's package hashing semantics exactly + -- rather than invoking `zig fetch` on a temporary copy. + -- + -- Requirements for that replacement: + -- + -- * Keep the source boundary independent of git or any other VCS. + -- * Preserve the current Acton package selection rules: include only the + -- canonical package inputs Acton knows about, currently Build.act and + -- src/. + -- * Match Zig's path normalization, directory ordering, file metadata/mode + -- treatment, digest algorithm, and final package-hash text encoding. + -- * Keep symlink behavior explicit. Today symlinks are rejected rather than + -- guessed; any future support must match Zig and have test coverage. + -- * Add stable fixture tests that compare Acton's in-place implementation + -- with `zig fetch` for representative package trees before switching over. + -- + -- Until then, copying to a clean temp directory keeps generated build output + -- out of the hash while delegating the actual hash algorithm to Zig. + copyCanonicalSource source staged + requireRightWith "ERROR: Failed to compute source hash: " =<< zigFetchHash zigExe staged + +copyCanonicalSource :: FilePath -> FilePath -> IO () +copyCanonicalSource src dst = do + copyRequiredSourceInput src dst "Build.act" + copyRequiredSourceInput src dst "src" + +copyRequiredSourceInput :: FilePath -> FilePath -> FilePath -> IO () +copyRequiredSourceInput src dst name = do + let srcEntry = src name + dstEntry = dst name + exists <- doesPathExist srcEntry + unless exists $ + throwProjectError ("ERROR: Artifact source hash missing package input: " ++ srcEntry) + copyCanonicalSourceInput srcEntry dstEntry + +copyCanonicalSourceInput :: FilePath -> FilePath -> IO () +copyCanonicalSourceInput src dst = do + isSymlink <- pathIsSymbolicLink src + when isSymlink $ + throwProjectError ("ERROR: Artifact source hash does not support symbolic links: " ++ src) + isDir <- doesDirectoryExist src + if isDir + then do + createDirectoryIfMissing True dst + entries <- sort <$> listDirectory src + forM_ entries $ \entry -> + copyCanonicalSourceInput (src entry) (dst entry) + else do + createDirectoryIfMissing True (takeDirectory dst) + copyFileWithMetadata src dst + +absolutizeLocalArtifactRef :: String -> IO String +absolutizeLocalArtifactRef ref + | Artifact.ociRefIsLocal ref = + case splitLocalOciTarget (Artifact.ociRefOrasTarget ref) of + Just (path, tag) -> do + path' <- makeAbsolute path + return ("oci-layout://" ++ path' ++ ":" ++ tag) + Nothing -> return ref + | otherwise = return ref + +splitLocalOciTarget :: String -> Maybe (FilePath, String) +splitLocalOciTarget target = + case break (== ':') (reverse target) of + (revTag, ':' : revPath) + | not (null revTag) && not (null revPath) -> + Just (reverse revPath, reverse revTag) + _ -> Nothing + +resolveArtifactRef :: String -> String -> String -> IO String +resolveArtifactRef sourceHash repoUrl artifactRepo + | not (null artifactRepo) = + case Artifact.ociRefForRepository artifactRepo sourceHash of + Just ref -> return ref + Nothing -> + throwProjectError ("ERROR: Invalid OCI artifact repository " ++ artifactRepo) + | not (null repoUrl) = + case Artifact.deriveOciRef repoUrl sourceHash of + Just ref -> return ref + Nothing -> + throwProjectError ("ERROR: Could not derive OCI artifact ref from " ++ repoUrl) + | otherwise = + throwProjectError "ERROR: Specify --artifact-repo or --repo-url for artifact push" + zigPkgAddCommand :: C.GlobalOptions -> C.ZigPkgAddOptions -> IO () zigPkgAddCommand _ opts = do let depName = C.zigPkgAddName opts diff --git a/compiler/acton/test.hs b/compiler/acton/test.hs index 23d2b3c4a..0abbcbc51 100644 --- a/compiler/acton/test.hs +++ b/compiler/acton/test.hs @@ -508,6 +508,24 @@ parseFlagTests = assertBool "serial parser flag should be set" (C.parse_serial (C.buildCompile buildOpts)) _ -> assertFailure "expected build command" + , testCase "build parser accepts artifact repositories" $ do + parsed <- parseArgs ["build", "--artifact-repo", "local-registry.local-domain.com/acton-out"] + case parsed of + C.CmdOpt _ (C.Build buildOpts) -> + assertEqual "artifact repos" + ["local-registry.local-domain.com/acton-out"] + (C.artifact_repos (C.buildCompile buildOpts)) + _ -> + assertFailure "expected build command" + , testCase "artifact parser accepts top-level push command" $ do + parsed <- parseArgs ["artifact", "push", "--artifact-repo", "registry.local/acton-out"] + case parsed of + C.CmdOpt _ (C.Artifact (C.ArtifactPush opts)) -> do + assertEqual "artifact repo" "registry.local/acton-out" (C.artifactPushArtifactRepo opts) + _ -> + assertFailure "expected artifact push command" + , testCase "artifact parser rejects raw push refs" $ do + assertParseFails ["artifact", "push", "--ref", "registry.local/acton-out:anything"] , testCase "build parser help includes --release alias" $ do helpText <- renderParserHelp ["build", "--help"] assertBool "help text should include --release" ("--release" `isInfixOf` helpText) @@ -776,6 +794,13 @@ parseFlagTests = OA.CompletionInvoked _ -> assertFailure ("parser requested shell completion for " ++ unwords args) + assertParseFails args = + case OA.execParserPure OA.defaultPrefs parserInfo args of + OA.Failure _ -> return () + OA.Success _ -> assertFailure ("parser unexpectedly accepted " ++ unwords args) + OA.CompletionInvoked _ -> + assertFailure ("parser requested shell completion for " ++ unwords args) + assertParsedBuildOptimize args expected = do parsed <- parseArgs args case parsed of @@ -1058,31 +1083,58 @@ actonProjTests = assertBool "root build.zig should bind the colliding zig dep separately" ("const dep_shared_2 = b.dependency(\"acton_zig_shared_2\"" `isInfixOf` rootBuildZig) , testCase "dep override path must be an Acton project root" $ do - withSystemTempDirectory "acton-invalid-dep-override" $ \proj -> do - let srcDir = proj "src" - buildAct = proj "Build.act" - mainAct = srcDir "main.act" - createDirectoryIfMissing True srcDir - createDirectoryIfMissing True (proj "deps") - writeFile buildAct $ unlines - [ "name = \"invalid_dep_override\"" - , "fingerprint = 0xb33bef4512345678" - , "" - , "dependencies = {" - , " \"dep_a\": (path=\"deps/dep_a_missing\")" - , "}" - ] - writeFile mainAct $ unlines - [ "actor main(env):" - , " print(\"hello\")" - , " env.exit(0)" - ] - actonExe <- canonicalizePath "../../dist/bin/acton" - (returnCode, _cmdOut, cmdErr) <- readCreateProcessWithExitCode (proc actonExe ["build", "--dep", "dep_a=deps"]){ cwd = Just proj } "" - assertEqual "acton should fail for invalid --dep path" (ExitFailure 1) returnCode - assertBool "error should mention bad dependency path" ("Dependency dep_a path is not an Acton project root" `isInfixOf` cmdErr) - assertBool "error should mention required project files" ("Build.act" `isInfixOf` cmdErr) - assertBool "error should mention src requirement" ("src/" `isInfixOf` cmdErr) + withSystemTempDirectory "acton-invalid-dep-override" $ \proj -> + withSystemTempDirectory "acton-artifact-dep" $ \artifactDep -> do + let srcDir = proj "src" + buildAct = proj "Build.act" + mainAct = srcDir "main.act" + invalidDepDir = proj "not_a_project_root" + createDirectoryIfMissing True srcDir + createDirectoryIfMissing True invalidDepDir + createDirectoryIfMissing True (artifactDep "out" "types") + writeFile buildAct $ unlines + [ "name = \"invalid_dep_override\"" + , "fingerprint = 0xb33bef4512345678" + , "" + , "dependencies = {" + , " \"dep_a\": (path=\"deps/dep_a_missing\")" + , "}" + ] + writeFile mainAct $ unlines + [ "actor main(env):" + , " print(\"hello\")" + , " env.exit(0)" + ] + writeFile (artifactDep "Build.act") $ unlines + [ "name = \"artifact_dep\"" + , "fingerprint = 0xa44bef4512345678" + , "dependencies = {}" + , "zig_dependencies = {}" + ] + writeFile (artifactDep "acton-artifact.json") "{}" + actonExe <- canonicalizePath "../../dist/bin/acton" + (returnCode, _cmdOut, cmdErr) <- readCreateProcessWithExitCode (proc actonExe ["build", "--dep", "dep_a=not_a_project_root"]){ cwd = Just proj } "" + assertEqual "acton should fail for invalid --dep path" (ExitFailure 1) returnCode + assertBool "error should mention bad dependency path" ("Dependency dep_a path is not an Acton project root" `isInfixOf` cmdErr) + assertBool "error should mention required project files" ("Build.act" `isInfixOf` cmdErr) + assertBool "error should mention src requirement" ("src/" `isInfixOf` cmdErr) + (artifactReturnCode, _artifactOut, artifactErr) <- readCreateProcessWithExitCode + (proc actonExe ["build", "--dep", "dep_a=" ++ artifactDep]){ cwd = Just proj } "" + assertEqual "acton should reject artifact roots as --dep paths" (ExitFailure 1) artifactReturnCode + assertBool "artifact root should still be rejected as a local source dep" + ("Dependency dep_a path is not an Acton project root" `isInfixOf` artifactErr) + writeFile buildAct $ unlines + [ "name = \"invalid_dep_override\"" + , "fingerprint = 0xb33bef4512345678" + , "" + , "dependencies = {" + , " \"dep_a\": (path=" ++ show artifactDep ++ ")" + , "}" + ] + (pathReturnCode, _pathOut, pathErr) <- readCreateProcessWithExitCode (proc actonExe ["build"]){ cwd = Just proj } "" + assertEqual "acton should reject artifact roots as Build.act paths" (ExitFailure 1) pathReturnCode + assertBool "path error should point users to artifact repos" + ("Use --artifact-repo to search output artifacts" `isInfixOf` pathErr) -- Verify pruning keeps binaries for modules that still have roots across build / test runs. , testCase "executable pruning" $ do @@ -1278,6 +1330,40 @@ pkgCliTests = case PkgCommands.decodePackageIndex (LBS.pack body) of Left _ -> return () Right _ -> assertFailure "package index entry without kinds should fail" + , testCase "artifact hash only includes package inputs" $ do + withSystemTempDirectory "acton-artifact-hash-inputs" $ \tmp -> do + acton <- canonicalizePath "../../dist/bin/acton" + let clean = tmp "clean" + noisy = tmp "noisy" + writePkg dir body = do + createDirectoryIfMissing True (dir "src") + writeFile (dir "Build.act") $ unlines + [ "name = \"hash_pkg\"" + , "fingerprint = 0xa33bef4512345678" + , "dependencies = {}" + , "zig_dependencies = {}" + ] + writeFile (dir "src" "main.act") body + hashPath dir = do + (code, out, err) <- readCreateProcessWithExitCode + (proc acton ["artifact", "hash", dir]) "" + assertEqual ("acton artifact hash should succeed\n" ++ err) ExitSuccess code + return (dropWhileEnd isSpace (dropWhile isSpace out)) + writePkg clean "actor main(env):\n env.exit(0)\n" + writePkg noisy "actor main(env):\n env.exit(0)\n" + createDirectoryIfMissing True (noisy "out" "types") + createDirectoryIfMissing True (noisy ".build") + writeFile (noisy "README.md") "not a package input\n" + writeFile (noisy "out" "types" "ignored.ty") "ignored\n" + writeFile (noisy ".build" "ignored") "ignored\n" + writeFile (noisy "build.zig") "ignored\n" + writeFile (noisy "build.zig.zon") "ignored\n" + cleanHash <- hashPath clean + noisyHash <- hashPath noisy + assertEqual "non-package files should not affect artifact source hash" cleanHash noisyHash + writeFile (noisy "src" "main.act") "actor main(env):\n print(\"changed\")\n env.exit(0)\n" + changedHash <- hashPath noisy + assertBool "source files should affect artifact source hash" (cleanHash /= changedHash) ] -- Creates testgroup from .act files found in specified directory diff --git a/compiler/lib/package.yaml.in b/compiler/lib/package.yaml.in index 12add4496..1d2641ce7 100644 --- a/compiler/lib/package.yaml.in +++ b/compiler/lib/package.yaml.in @@ -61,6 +61,7 @@ dependencies: library: source-dirs: src exposed-modules: + - Acton.Artifact - Acton.Boxing - Acton.BuildSpec - Acton.Builtin diff --git a/compiler/lib/src/Acton/Artifact.hs b/compiler/lib/src/Acton/Artifact.hs new file mode 100644 index 000000000..03ecf11cc --- /dev/null +++ b/compiler/lib/src/Acton/Artifact.hs @@ -0,0 +1,255 @@ +-- Copyright (C) 2019-2021 Data Ductus AB +-- +-- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +-- +-- 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +-- +-- 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +-- +-- 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +-- +-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-- + +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +module Acton.Artifact + ( ActonOutManifest(..) + , artifactFormatVersion + , artifactManifestFile + , artifactArchiveFile + , artifactRepositoryName + , artifactMediaType + , artifactType + , currentTargetTuple + , currentInterfaceVersion + , currentInterfaceVersionText + , expectedManifest + , validateManifest + , encodeManifest + , decodeManifest + , writeManifest + , readManifest + , ociTagForSourceHash + , ociRefForRepository + , deriveOciRef + , ociRefWithoutScheme + , ociRefIsLocal + , ociRefOrasOptions + , ociRefOrasTarget + ) where + +import GHC.Generics (Generic) +import Control.Exception (SomeException, displayException, try) +import Data.Aeson (FromJSON(..), ToJSON(..), (.:), (.=)) +import qualified Data.Aeson as Ae +import qualified Data.ByteString.Lazy as BL +import Data.Char (isAlphaNum) +import Data.List (intercalate, isPrefixOf, isSuffixOf) +import qualified Acton.Syntax as A +import System.FilePath (()) + +data ActonOutManifest = ActonOutManifest + { manifestArtifactFormat :: Int + , manifestSourceHash :: String + , manifestInterfaceVersion :: [Int] + , manifestTargetTuple :: String + , manifestContents :: [String] + } deriving (Eq, Show, Generic) + +instance FromJSON ActonOutManifest where + parseJSON = Ae.withObject "ActonOutManifest" $ \o -> + ActonOutManifest <$> o .: "artifact_format" + <*> o .: "source_hash" + <*> o .: "interface_version" + <*> o .: "target_tuple" + <*> o .: "contents" + +instance ToJSON ActonOutManifest where + toJSON m = Ae.object + [ "artifact_format" .= manifestArtifactFormat m + , "source_hash" .= manifestSourceHash m + , "interface_version" .= manifestInterfaceVersion m + , "target_tuple" .= manifestTargetTuple m + , "contents" .= manifestContents m + ] + +artifactFormatVersion :: Int +artifactFormatVersion = 1 + +artifactManifestFile :: FilePath +artifactManifestFile = "acton-artifact.json" + +artifactArchiveFile :: FilePath +artifactArchiveFile = "acton-out.tar.gz" + +artifactRepositoryName :: String +artifactRepositoryName = "acton-out" + +artifactMediaType :: String +artifactMediaType = "application/vnd.acton.out.v1+tar+gzip" + +artifactType :: String +artifactType = "application/vnd.acton.out.v1" + +currentTargetTuple :: String +currentTargetTuple = "" + +currentInterfaceVersion :: [Int] +currentInterfaceVersion = A.version + +currentInterfaceVersionText :: String +currentInterfaceVersionText = intercalate "." (map show currentInterfaceVersion) + +expectedManifest :: String -> ActonOutManifest +expectedManifest sourceHash = + ActonOutManifest + { manifestArtifactFormat = artifactFormatVersion + , manifestSourceHash = sourceHash + , manifestInterfaceVersion = currentInterfaceVersion + , manifestTargetTuple = currentTargetTuple + , manifestContents = [artifactManifestFile, "Build.act", "out/types"] + } + +validateManifest :: String -> ActonOutManifest -> Either String () +validateManifest sourceHash manifest + | manifestArtifactFormat manifest /= artifactFormatVersion = + Left ("unsupported artifact format " + ++ show (manifestArtifactFormat manifest) + ++ ", expected " + ++ show artifactFormatVersion) + | manifestSourceHash manifest /= sourceHash = + Left ("artifact source hash " + ++ manifestSourceHash manifest + ++ " does not match dependency source hash " + ++ sourceHash) + | manifestInterfaceVersion manifest /= currentInterfaceVersion = + Left ("artifact interface version " + ++ show (manifestInterfaceVersion manifest) + ++ " does not match compiler interface version " + ++ show currentInterfaceVersion) + | manifestTargetTuple manifest /= currentTargetTuple = + Left ("artifact target tuple " + ++ show (manifestTargetTuple manifest) + ++ " does not match expected " + ++ show currentTargetTuple) + | otherwise = Right () + +encodeManifest :: ActonOutManifest -> BL.ByteString +encodeManifest = Ae.encode + +decodeManifest :: BL.ByteString -> Either String ActonOutManifest +decodeManifest = Ae.eitherDecode' + +writeManifest :: FilePath -> ActonOutManifest -> IO () +writeManifest dir manifest = + BL.writeFile (dir artifactManifestFile) (encodeManifest manifest) + +readManifest :: FilePath -> IO (Either String ActonOutManifest) +readManifest dir = do + bytes <- try (BL.readFile (dir artifactManifestFile)) :: IO (Either SomeException BL.ByteString) + case bytes of + Left ex -> return (Left (displayException ex)) + Right bs -> return (decodeManifest bs) + +ociTagForSourceHash :: String -> Maybe String +ociTagForSourceHash sourceHash = + let tag = sourceHash ++ "-iface" ++ currentInterfaceVersionText + in if validOciTag tag then Just tag else Nothing + +deriveOciRef :: String -> String -> Maybe String +deriveOciRef repoUrl sourceHash = + case parseRepoPath repoUrl of + Just ("github.com", [owner, repo]) -> + ociRefForRepository ("ghcr.io/" ++ owner ++ "/" ++ repo ++ "/" ++ artifactRepositoryName) sourceHash + Just ("gitlab.com", parts@(_:_)) -> + ociRefForRepository ("registry.gitlab.com/" ++ intercalate "/" parts ++ "/" ++ artifactRepositoryName) sourceHash + _ -> Nothing + +ociRefForRepository :: String -> String -> Maybe String +ociRefForRepository repo sourceHash = do + tag <- ociTagForSourceHash sourceHash + case localOciLayoutPath repo of + Just path -> + let path' = dropTrailingSlash path + in if null path' + then Nothing + else Just ("oci-layout://" ++ path' ++ ":" ++ tag) + Nothing -> + let repo' = dropTrailingSlash (ociRefWithoutScheme repo) + in if null repo' || taggedRepository repo' + then Nothing + else Just ("oci://" ++ repo' ++ ":" ++ tag) + +ociRefWithoutScheme :: String -> String +ociRefWithoutScheme ref + | "oci-layout://" `isPrefixOf` ref = drop (length ("oci-layout://" :: String)) ref + | "oci://" `isPrefixOf` ref = drop (length ("oci://" :: String)) ref + | otherwise = ref + +ociRefIsLocal :: String -> Bool +ociRefIsLocal ref = "oci-layout://" `isPrefixOf` ref + +ociRefOrasOptions :: String -> [String] +ociRefOrasOptions ref + | ociRefIsLocal ref = ["--oci-layout"] + | otherwise = [] + +ociRefOrasTarget :: String -> String +ociRefOrasTarget = ociRefWithoutScheme + +localOciLayoutPath :: String -> Maybe FilePath +localOciLayoutPath repo + | "oci-layout://" `isPrefixOf` repo = + Just (drop (length ("oci-layout://" :: String)) repo) + | "/" `isPrefixOf` repo = Just repo + | "./" `isPrefixOf` repo = Just repo + | "../" `isPrefixOf` repo = Just repo + | otherwise = Nothing + +taggedRepository :: String -> Bool +taggedRepository ref = + let lastPart = reverse (takeWhile (/= '/') (reverse ref)) + in ':' `elem` lastPart + +validOciTag :: String -> Bool +validOciTag [] = False +validOciTag tag = + length tag <= 128 && validFirst (head tag) && all validRest tag + where + validFirst c = isAlphaNum c || c == '_' + validRest c = isAlphaNum c || c == '_' || c == '.' || c == '-' + +parseRepoPath :: String -> Maybe (String, [String]) +parseRepoPath raw = + let noScheme = dropPrefix "https://" (dropPrefix "http://" raw) + noFragment = takeWhile (/= '#') noScheme + (host, rest0) = break (== '/') noFragment + rest = dropWhile (== '/') rest0 + parts = pathParts (dropGitSuffix (dropTrailingSlash rest)) + in case host of + "github.com" | length parts >= 2 -> Just (host, take 2 parts) + "gitlab.com" | length parts >= 2 -> Just (host, parts) + _ -> Nothing + +dropPrefix :: String -> String -> String +dropPrefix prefix s + | prefix `isPrefixOf` s = drop (length prefix) s + | otherwise = s + +dropGitSuffix :: String -> String +dropGitSuffix s + | ".git" `isSuffixOf` s = take (length s - 4) s + | otherwise = s + +dropTrailingSlash :: String -> String +dropTrailingSlash s + | "/" `isSuffixOf` s = dropTrailingSlash (take (length s - 1) s) + | otherwise = s + +pathParts :: String -> [String] +pathParts "" = [] +pathParts s = + let (x, rest) = break (== '/') s + rest' = dropWhile (== '/') rest + in if null x then pathParts rest' else x : pathParts rest' diff --git a/compiler/lib/src/Acton/CommandLineParser.hs b/compiler/lib/src/Acton/CommandLineParser.hs index c87129f29..cc14eaec6 100644 --- a/compiler/lib/src/Acton/CommandLineParser.hs +++ b/compiler/lib/src/Acton/CommandLineParser.hs @@ -52,6 +52,7 @@ data Command = New NewOptions | PkgUpgrade PkgUpgradeOptions | PkgUpdate | PkgSearch PkgSearchOptions + | Artifact ArtifactCommand | BuildSpecCmd BuildSpecCommand | Cloud CloudOptions | Doc DocOptions @@ -98,7 +99,8 @@ data CompileOptions = CompileOptions { cpu :: String, test :: Bool, searchpath :: [String], - dep_overrides :: [(String,String)] + dep_overrides :: [(String,String)], + artifact_repos :: [String] } deriving Show data BuildOptions = BuildOptions @@ -192,6 +194,25 @@ data PkgSearchOptions = PkgSearchOptions { pkgSearchTerms :: [String] } deriving Show +data ArtifactCommand + = ArtifactPack ArtifactPackOptions + | ArtifactPush ArtifactPushOptions + | ArtifactHash ArtifactHashOptions + deriving Show + +data ArtifactPackOptions = ArtifactPackOptions + { artifactPackOutput :: String + } deriving Show + +data ArtifactPushOptions = ArtifactPushOptions + { artifactPushRepoUrl :: String + , artifactPushArtifactRepo :: String + } deriving Show + +data ArtifactHashOptions = ArtifactHashOptions + { artifactHashSourcePath :: String + } deriving Show + data ZigPkgAddOptions = ZigPkgAddOptions { zigPkgAddUrl :: String , zigPkgAddName :: String @@ -215,6 +236,7 @@ cmdLineParser = hsubparser <> command "test" (info (CmdOpt <$> globalOptions <*> (Test <$> testCommand)) (progDesc "Build and run project tests")) <> command "fetch" (info (CmdOpt <$> globalOptions <*> pure Fetch) (progDesc "Fetch project dependencies (offline prep)")) <> command "pkg" (info (CmdOpt <$> globalOptions <*> pkgSubcommands) (progDesc "Library package/dependency commands")) + <> command "artifact" (info (CmdOpt <$> globalOptions <*> (Artifact <$> artifactSubcommands)) (progDesc "Build or publish Acton output artifacts")) <> command "zig-pkg" (info (CmdOpt <$> globalOptions <*> zigPkgSubcommands) (progDesc "Zig package dependency commands")) <> command "spec" (info (CmdOpt <$> globalOptions <*> (BuildSpecCmd <$> buildSpecCommand)) (progDesc "Inspect or update build specification")) <> command "cloud" (info (CmdOpt <$> globalOptions <*> (Cloud <$> cloudOptions)) (progDesc "Run an Acton project in the cloud")) @@ -343,6 +365,7 @@ sigCompileOptions = mkSigCompileOptions , test = False , searchpath = search , dep_overrides = depOverrides + , artifact_repos = [] } compileOptions = CompileOptions @@ -382,6 +405,7 @@ compileOptions = CompileOptions (long "dep" <> metavar "NAME=PATH" <> help "Override dependency NAME with local PATH")) + <*> many (strOption (long "artifact-repo" <> metavar "OCI_REPO" <> help "Search OCI repository for Acton output artifacts")) pkgSubcommands :: Parser Command pkgSubcommands = hsubparser @@ -424,6 +448,26 @@ pkgSearchOptions :: Parser PkgSearchOptions pkgSearchOptions = PkgSearchOptions <$> many (argument str (metavar "TERM" <> help "Search term (regex, ANDed)")) +artifactSubcommands :: Parser ArtifactCommand +artifactSubcommands = hsubparser + ( command "pack" (info (ArtifactPack <$> artifactPackOptions) (progDesc "Pack out/types as an Acton artifact")) + <> command "push" (info (ArtifactPush <$> artifactPushOptions) (progDesc "Pack and push an Acton artifact to OCI")) + <> command "hash" (info (ArtifactHash <$> artifactHashOptions) (progDesc "Compute the Acton package source hash for a local path")) + ) + +artifactPackOptions :: Parser ArtifactPackOptions +artifactPackOptions = ArtifactPackOptions + <$> strOption (long "output" <> metavar "FILE" <> value "" <> help "Output archive (defaults to out/acton-out.tar.gz)") + +artifactPushOptions :: Parser ArtifactPushOptions +artifactPushOptions = ArtifactPushOptions + <$> strOption (long "repo-url" <> metavar "URL" <> value "" <> help "Repository URL used to derive the OCI ref") + <*> strOption (long "artifact-repo" <> metavar "OCI_REPO" <> value "" <> help "OCI repository to push to") + +artifactHashOptions :: Parser ArtifactHashOptions +artifactHashOptions = + ArtifactHashOptions <$> argument str (metavar "PATH" <> value "." <> help "Source package path") + zigPkgSubcommands :: Parser Command zigPkgSubcommands = hsubparser ( command "add" (info (ZigPkgAdd <$> zigPkgAddOptions) (progDesc "Add Zig package dependency")) diff --git a/compiler/lib/src/Acton/Compile.hs b/compiler/lib/src/Acton/Compile.hs index 09ca58b28..f2b239e4d 100644 --- a/compiler/lib/src/Acton/Compile.hs +++ b/compiler/lib/src/Acton/Compile.hs @@ -209,6 +209,7 @@ import qualified Acton.LambdaLifter import qualified Acton.Boxing import qualified Acton.CodeGen import Acton.Prim (mPrim) +import qualified Acton.Artifact as Artifact import qualified Acton.BuildSpec as BuildSpec import qualified Acton.DocPrinter as DocP import qualified Acton.Fingerprint as Fingerprint @@ -618,7 +619,7 @@ prepareCompilePlan sp gopts sched opts srcFiles allowPrune mChangedPaths = do ctx <- prepareCompileContext opts srcFiles specChanged <- checkBuildSpecChange sched (ccBuildStamp ctx) when specChanged $ - fetchDependencies gopts (ccPathsRoot ctx) (ccDepOverrides ctx) + fetchDependencies gopts (ccPathsRoot ctx) (ccDepOverrides ctx) (C.artifact_repos opts) let mChanged = if specChanged then Nothing else mChangedPaths prepareCompilePlanFromContext sp gopts ctx srcFiles allowPrune mChanged @@ -661,6 +662,7 @@ prepareCompilePlanFromContext sp gopts ctx srcFiles allowPrune mChangedPaths = d , projSysTypes = joinPath [sysAbs, "base", "out", "types"] , projBuildSpec = scratchBuildSpec rootProj , projDeps = [] + , projPrebuilt = False } return (M.singleton rootProj ctx') else discoverProjects gopts sysAbs rootProj depOverrides @@ -797,6 +799,7 @@ defaultCompileOptions = , C.test = False , C.searchpath = [] , C.dep_overrides = [] + , C.artifact_repos = [] } -- | Debug helper for pass dumps. @@ -1192,7 +1195,9 @@ buildGlobalTasks sp gopts opts projMap mSeeds = do Just actFile -> do let ctx = projMap M.! tkProj k paths <- pathsForModule opts projMap ctx (tkMod k) - task <- readModuleTask sp gopts opts paths actFile + task <- if projPrebuilt ctx + then readPrebuiltModuleTask paths actFile + else readModuleTask sp gopts opts paths actFile let order = M.findWithDefault [tkProj k] (tkProj k) orderCache providers = resolveProviders order modSets (importsOf task) newKeys = M.elems providers @@ -1483,6 +1488,28 @@ readModuleTask sp gopts opts paths actFile = do HeadError{ mhName = mn, mhDiagnostics = diags } -> ParseErrorTask mn diags +readPrebuiltModuleTask :: Paths -> FilePath -> IO CompileTask +readPrebuiltModuleTask paths tyFile = do + let mn = modName paths + hdrE <- (try :: IO a -> IO (Either SomeException a)) $ InterfaceFiles.readHeader tyFile + case hdrE of + Left ex -> + throwProjectError ("Invalid prebuilt Acton interface " ++ tyFile ++ ": " ++ displayException ex) + Right (_sourceMeta, moduleSrcBytesHash, modulePubHash, moduleImplHash, imps, nameHashes, roots, tests, mdoc) -> + let nmodStub = I.NModule [] [] mdoc + tmodStub = A.Module mn [] mdoc [] + in return TyTask { name = mn + , tyHash = moduleSrcBytesHash + , tyPubHash = modulePubHash + , tyImplHash = moduleImplHash + , tyImports = imps + , tyNameHashes = nameHashes + , tyRoots = roots + , tyTests = tests + , tyDoc = mdoc + , iface = nmodStub + , typed = tmodStub } + readModuleDoc :: Source.SourceProvider -> C.GlobalOptions -> C.CompileOptions @@ -3053,7 +3080,8 @@ data ProjCtx = ProjCtx { projSysPath :: FilePath, projSysTypes:: FilePath, projBuildSpec :: BuildSpec.BuildSpec, - projDeps :: [(String, FilePath)] -- resolved dependency roots (abs paths) + projDeps :: [(String, FilePath)], -- resolved dependency roots (abs paths) + projPrebuilt :: Bool } deriving (Show) type FingerprintMap = M.Map String FilePath @@ -3118,7 +3146,8 @@ discoverProjects gopts sysAbs rootProj depOverrides = do let outDir = joinPath [dirAbs, "out"] typesDir = joinPath [outDir, "types"] srcDir' = joinPath [dirAbs, "src"] - ctx = ProjCtx { projRoot = dirAbs + prebuilt <- isActonArtifactOnlyRoot dirAbs + let ctx = ProjCtx { projRoot = dirAbs , projOutDir = outDir , projTypesDir = typesDir , projSrcDir = srcDir' @@ -3126,6 +3155,7 @@ discoverProjects gopts sysAbs rootProj depOverrides = do , projSysTypes = joinPath [sysAbs, "base", "out", "types"] , projBuildSpec = spec , projDeps = [ (n, p) | (n, p, _) <- reverse deps ] + , projPrebuilt = prebuilt } acc' = M.insert dirAbs ctx acc seen' = Data.Set.insert dirAbs seen @@ -3145,6 +3175,7 @@ discoverProjects gopts sysAbs rootProj depOverrides = do ++ " overridden by root pin") depBase <- resolveDepBase base depName chosenDep depAbs <- normalizePathSafe depBase + validateDependencyPath depName chosenDep depAbs (depPath, fpMap', depSpec) <- canonicalizeDep depAbs fpMap return ((depName, depPath, depSpec) : accDeps, fpMap') @@ -3205,6 +3236,19 @@ isActonProjectRoot path = do hasSrcDir <- doesDirectoryExist (path "src") return (hasBuildAct && hasSrcDir) +isActonArtifactRoot :: FilePath -> IO Bool +isActonArtifactRoot path = do + hasManifest <- doesFileExist (path Artifact.artifactManifestFile) + hasBuildAct <- doesFileExist (path "Build.act") + hasTypesDir <- doesDirectoryExist (path "out" "types") + return (hasManifest && hasBuildAct && hasTypesDir) + +isActonArtifactOnlyRoot :: FilePath -> IO Bool +isActonArtifactOnlyRoot path = do + isArtifact <- isActonArtifactRoot path + hasSrcDir <- doesDirectoryExist (path "src") + return (isArtifact && not hasSrcDir) + findProjectDir :: FilePath -> IO (Maybe FilePath) findProjectDir path = do isProjectRoot <- isActonProjectRoot path @@ -3324,14 +3368,29 @@ moduleNameFromFile srcBase actFile = do -- Used to seed the project module index for graph construction. enumerateProjectModules :: ProjCtx -> IO [(FilePath, A.ModName)] enumerateProjectModules ctx = do - exists <- doesDirectoryExist (projSrcDir ctx) + if projPrebuilt ctx + then enumeratePrebuiltModules ctx + else do + exists <- doesDirectoryExist (projSrcDir ctx) + if not exists + then return [] + else do + files <- getFilesRecursive (projSrcDir ctx) + let actFiles = filter (\f -> takeExtension f == ".act") files + forM actFiles $ \f -> do + mn <- moduleNameFromFile (projSrcDir ctx) f + return (f, mn) + +enumeratePrebuiltModules :: ProjCtx -> IO [(FilePath, A.ModName)] +enumeratePrebuiltModules ctx = do + exists <- doesDirectoryExist (projTypesDir ctx) if not exists then return [] else do - files <- getFilesRecursive (projSrcDir ctx) - let actFiles = filter (\f -> takeExtension f == ".act") files - forM actFiles $ \f -> do - mn <- moduleNameFromFile (projSrcDir ctx) f + files <- getFilesRecursive (projTypesDir ctx) + let tyFiles = filter (\f -> takeExtension f == ".ty") files + forM tyFiles $ \f -> do + mn <- moduleNameFromFile (projTypesDir ctx) f return (f, mn) -- | Remove stale generated module artifacts when source modules disappear. @@ -3339,20 +3398,21 @@ enumerateProjectModules ctx = do -- cached headers cannot mask deleted .act modules. pruneMissingModuleOutputs :: ProjCtx -> IO () pruneMissingModuleOutputs ctx = do - let srcRoot = projSrcDir ctx - typesRoot = projTypesDir ctx - typesExists <- doesDirectoryExist typesRoot - when typesExists $ do - srcExists <- doesDirectoryExist srcRoot - srcMods <- if srcExists - then do - srcFiles <- getFilesRecursive srcRoot - let actFiles = filter (\f -> takeExtension f == ".act") srcFiles - modBases = map (normalise . dropExtension . makeRelative srcRoot) actFiles - return (Data.Set.fromList modBases) - else return Data.Set.empty - outFiles <- getFilesRecursive typesRoot - mapM_ (pruneFile srcMods typesRoot) outFiles + unless (projPrebuilt ctx) $ do + let srcRoot = projSrcDir ctx + typesRoot = projTypesDir ctx + typesExists <- doesDirectoryExist typesRoot + when typesExists $ do + srcExists <- doesDirectoryExist srcRoot + srcMods <- if srcExists + then do + srcFiles <- getFilesRecursive srcRoot + let actFiles = filter (\f -> takeExtension f == ".act") srcFiles + modBases = map (normalise . dropExtension . makeRelative srcRoot) actFiles + return (Data.Set.fromList modBases) + else return Data.Set.empty + outFiles <- getFilesRecursive typesRoot + mapM_ (pruneFile srcMods typesRoot) outFiles where isRootStub rel ext = ext == ".c" && @@ -3385,13 +3445,14 @@ pruneMissingChangedModuleOutputs ctxs changedPaths = do mapM_ (pruneForCtx actPath) ctxs where pruneForCtx actPath ctx = do - srcRoot <- normalizePathSafe (projSrcDir ctx) - let srcRoot' = addTrailingPathSeparator (normalise srcRoot) - actPath' = normalise actPath - when (Data.List.isPrefixOf srcRoot' actPath') $ do - let modBase = normalise (dropExtension (makeRelative srcRoot actPath')) - outBase = projTypesDir ctx modBase - mapM_ (\ext -> removeFile (outBase ++ ext) `catch` ignoreNotExists) [".ty", ".c", ".h"] + unless (projPrebuilt ctx) $ do + srcRoot <- normalizePathSafe (projSrcDir ctx) + let srcRoot' = addTrailingPathSeparator (normalise srcRoot) + actPath' = normalise actPath + when (Data.List.isPrefixOf srcRoot' actPath') $ do + let modBase = normalise (dropExtension (makeRelative srcRoot actPath')) + outBase = projTypesDir ctx modBase + mapM_ (\ext -> removeFile (outBase ++ ext) `catch` ignoreNotExists) [".ty", ".c", ".h"] ignoreNotExists :: IOException -> IO () ignoreNotExists _ = return () @@ -3601,12 +3662,18 @@ applyDepOverrides base overrides spec = do Just dep -> do let absP0 = if isAbsolutePath depPath then depPath else joinPath [base, depPath] absP <- normalizePathSafe absP0 - validateDepOverridePath depName absP + validateLocalDepPath depName absP let dep' = dep { BuildSpec.path = Just absP } return (M.insert depName dep' depsMap) -validateDepOverridePath :: String -> FilePath -> IO () -validateDepOverridePath depName depPath = do +validateDependencyPath :: String -> BuildSpec.PkgDep -> FilePath -> IO () +validateDependencyPath depName dep depPath = + case BuildSpec.path dep of + Just p | not (null p) -> rejectArtifactDepPath depName depPath + _ -> return () + +validateLocalDepPath :: String -> FilePath -> IO () +validateLocalDepPath depName depPath = do exists <- doesDirectoryExist depPath unless exists $ throwProjectError ("Dependency " ++ depName ++ " path does not exist: " ++ depPath ++ "\n" @@ -3618,8 +3685,15 @@ validateDepOverridePath depName depPath = do ++ "Hint: Local dependency paths must point to an Acton project root\n" ++ "(directory with src/ and Build.act).") -fetchDependencies :: C.GlobalOptions -> Paths -> [(String, FilePath)] -> IO () -fetchDependencies gopts paths depOverrides = do +rejectArtifactDepPath :: String -> FilePath -> IO () +rejectArtifactDepPath depName depPath = do + isArtifactRoot <- isActonArtifactOnlyRoot depPath + when isArtifactRoot $ + throwProjectError ("Dependency " ++ depName ++ " path points to an Acton output artifact: " ++ depPath ++ "\n" + ++ "Hint: Use --artifact-repo to search output artifacts; local dependency paths are not artifact roots.") + +fetchDependencies :: C.GlobalOptions -> Paths -> [(String, FilePath)] -> [String] -> IO () +fetchDependencies gopts paths depOverrides artifactRepos = do if isTmp paths then return () else do @@ -3647,7 +3721,7 @@ fetchDependencies gopts paths depOverrides = do selectedDeps <- forM (M.toList (BuildSpec.dependencies spec)) $ selectDependency rootPins projAbs let pkgFetches = catMaybes - [ mkPkgFetch cacheDir zigExe globalCache name dep | (name, dep) <- selectedDeps ] + [ mkPkgFetch cacheDir zigExe globalCache depsCache name dep | (name, dep) <- selectedDeps ] zigFetches = catMaybes [ mkZigFetch cacheDir zigExe globalCache name dep | (name, dep) <- M.toList (BuildSpec.zig_dependencies spec) @@ -3686,16 +3760,17 @@ fetchDependencies gopts paths depOverrides = do ++ "(directory with src/ and Build.act).") _ -> return seen else do + validateDependencyPath depName dep depAbs depSpec0 <- loadBuildSpec depAbs depSpec <- applyDepOverrides depAbs depOverrides depSpec0 walkProject rootPins cacheDir zigExe globalCache depsCache seen depAbs depSpec - mkPkgFetch cacheDir zigExe globalCache name dep = + mkPkgFetch cacheDir zigExe globalCache depsCache name dep = case BuildSpec.path dep of Just p | not (null p) -> Nothing _ -> case (BuildSpec.url dep, BuildSpec.hash dep) of (Just u, Just h) -> - Just (fetchOne "pkg" name u (Just h) cacheDir zigExe globalCache) + Just (fetchPkg name dep u h cacheDir zigExe globalCache depsCache) (Just _, Nothing) -> Just (return (Left ("Dependency " ++ name ++ " is missing hash"))) _ -> Nothing @@ -3731,6 +3806,209 @@ fetchDependencies gopts paths depOverrides = do then copyTree src dst else extractCachedArchive srcArchive dst + fetchPkg name dep url h cacheDir zigExe globalCache depsCache = do + let dst = joinPath [depsCache, name ++ "-" ++ h] + present <- doesDirectoryExist dst + if present + then do + cachedArtifact <- validateCachedArtifactDir name h dst + case cachedArtifact of + Left err -> return (Left err) + Right True -> useCachedArtifact name h dst + Right False -> do + stillPresent <- doesDirectoryExist dst + if stillPresent + then useCachedPkg name h dst + else fetchPkgFresh name dep url h cacheDir zigExe globalCache depsCache dst + else fetchPkgFresh name dep url h cacheDir zigExe globalCache depsCache dst + + useCachedArtifact name h _dst = do + unless (C.quiet gopts) $ + putStrLn ("Using cached Acton artifact for dependency " ++ name ++ " (" ++ h ++ ")") + return (Right h) + + useCachedPkg name h _dst = do + unless (C.quiet gopts) $ + putStrLn ("Using cached pkg dependency " ++ name ++ " (" ++ h ++ ")") + return (Right h) + + fetchPkgFresh name dep url h cacheDir zigExe globalCache depsCache dst = do + sourcePresent <- doesDirectoryExist (cacheDir h) + if sourcePresent + then fetchOne "pkg" name url (Just h) cacheDir zigExe globalCache + else do + artifact <- fetchPkgArtifact name dep h dst depsCache + case artifact of + Left err -> return (Left err) + Right True -> return (Right h) + Right False -> fetchOne "pkg" name url (Just h) cacheDir zigExe globalCache + + validateCachedArtifactDir name h dst = do + artifactRoot <- isActonArtifactOnlyRoot dst + if not artifactRoot + then return (Right False) + else do + valid <- validateArtifactDir name h dst + case valid of + Right () -> return (Right True) + Left err -> do + when (C.verbose gopts) $ + putStrLn ("Ignoring cached Acton artifact for " ++ name ++ ": " ++ err) + rm <- try (removePathForcibly dst) :: IO (Either IOException ()) + case rm of + Right _ -> return (Right False) + Left ex -> return (Left ("Failed to remove stale Acton artifact " ++ dst ++ ": " ++ displayException ex)) + + fetchPkgArtifact name dep h dst depsCache = do + refs <- artifactRefs dep h + case refs of + Left err -> return (Left err) + Right allRefs -> tryArtifactRefs name h dst depsCache (localArtifactRefs allRefs ++ remoteArtifactRefs allRefs) + + localArtifactRefs refs = filter Artifact.ociRefIsLocal refs + + remoteArtifactRefs refs = filter (not . Artifact.ociRefIsLocal) refs + + artifactRefs dep h = + let (repoErrors, repoRefs) = partitionEithers [ artifactRepoRef repo h | repo <- artifactRepos ] + in case repoErrors of + err:_ -> return (Left err) + [] -> return (Right (repoRefs ++ derivedArtifactRefs dep h)) + where + artifactRepoRef repo h = + case Artifact.ociRefForRepository repo h of + Just ref -> Right ref + Nothing -> Left ("Invalid Acton artifact repository " ++ repo) + + derivedArtifactRefs dep h = + catMaybes [ BuildSpec.repo_url dep >>= \repoUrl -> Artifact.deriveOciRef repoUrl h ] + + tryArtifactRefs name h dst depsCache refs = + case refs of + [] -> return (Right False) + ref:rest -> do + unless (C.quiet gopts) $ + putStrLn ("Trying Acton artifact " ++ name ++ " from " ++ ref) + tmpRoot <- getTemporaryDirectory + nonce <- randomRIO (0, maxBound :: Int) + let tmpDir = joinPath [tmpRoot, "acton-oci-" ++ show nonce] + pullDir = joinPath [tmpDir, "pull"] + stageDir = joinPath [tmpDir, "stage"] + refArg = Artifact.ociRefOrasTarget ref + pullArgs = ["pull"] ++ Artifact.ociRefOrasOptions ref ++ [refArg, "--output", pullDir] + createDirectoryIfMissing True pullDir + pullRes <- runProcessCapture "oras" pullArgs Nothing + case pullRes of + Left _ -> cleanupTmp tmpDir >> tryArtifactRefs name h dst depsCache rest + Right (ExitFailure _, _, _) -> cleanupTmp tmpDir >> tryArtifactRefs name h dst depsCache rest + Right (ExitSuccess, _, _) -> do + let archive = pullDir Artifact.artifactArchiveFile + archiveExists <- doesFileExist archive + if not archiveExists + then do + skipArtifact name h dst depsCache ref tmpDir rest ("did not contain " ++ Artifact.artifactArchiveFile) + else do + createDirectoryIfMissing True stageDir + extracted <- extractArtifactArchive ref archive stageDir + case extracted of + Left err -> skipArtifact name h dst depsCache ref tmpDir rest err + Right () -> do + valid <- validateArtifactDir name h stageDir + case valid of + Left err -> skipArtifact name h dst depsCache ref tmpDir rest err + Right () -> do + createDirectoryIfMissing True depsCache + dstExists <- doesDirectoryExist dst + materialized <- if dstExists + then try (removePathForcibly dst >> renameDirectory stageDir dst) :: IO (Either IOException ()) + else try (renameDirectory stageDir dst) :: IO (Either IOException ()) + case materialized of + Right _ -> do + cleanupTmp tmpDir + unless (C.quiet gopts) $ + putStrLn ("Using Acton artifact for dependency " ++ name ++ " (" ++ h ++ ")") + return (Right True) + Left ex -> do + cleanupTmp tmpDir + return (Left ("Failed to materialize Acton artifact " ++ ref ++ ": " ++ displayException ex)) + + skipArtifact name h dst depsCache ref tmpDir rest reason = do + when (C.verbose gopts) $ + putStrLn ("Ignoring Acton artifact " ++ ref ++ ": " ++ reason) + cleanupTmp tmpDir + tryArtifactRefs name h dst depsCache rest + + validateArtifactDir name h dir = do + manifestE <- Artifact.readManifest dir + case manifestE of + Left err -> return (Left ("Invalid Acton artifact manifest for " ++ name ++ ": " ++ err)) + Right manifest -> + case Artifact.validateManifest h manifest of + Left err -> return (Left ("Invalid Acton artifact manifest for " ++ name ++ ": " ++ err)) + Right () -> do + hasBuildAct <- doesFileExist (dir "Build.act") + hasTypesDir <- doesDirectoryExist (dir "out" "types") + if hasBuildAct && hasTypesDir + then return (Right ()) + else return (Left ("Acton artifact for " ++ name ++ " must contain Build.act and out/types")) + + extractArtifactArchive ref archive stageDir = do + safe <- validateArtifactArchiveEntries archive + case safe of + Left err -> + return (Left ("Unsafe Acton artifact " ++ ref ++ ": " ++ err)) + Right () -> do + extractRes <- runProcessCapture "tar" ["-xzf", archive, "-C", stageDir] Nothing + case extractRes of + Left ex -> + return (Left ("Failed to extract Acton artifact " ++ ref ++ ": " ++ displayException ex)) + Right (ExitFailure _, _, err) -> + return (Left ("Failed to extract Acton artifact " ++ ref ++ ":\n" ++ err)) + Right (ExitSuccess, _, _) -> return (Right ()) + + validateArtifactArchiveEntries archive = do + namesRes <- runProcessCapture "tar" ["-tzf", archive] Nothing + case namesRes of + Left ex -> return (Left ("failed to list archive entries: " ++ displayException ex)) + Right (ExitFailure _, _, err) -> return (Left ("failed to list archive entries:\n" ++ err)) + Right (ExitSuccess, out, _) -> do + let entries = filter (not . null) (lines out) + badPaths = filter (not . safeArtifactPath) entries + if not (null badPaths) + then return (Left ("invalid archive path " ++ head badPaths)) + else do + typesRes <- runProcessCapture "tar" ["-tvzf", archive] Nothing + case typesRes of + Left ex -> return (Left ("failed to inspect archive entries: " ++ displayException ex)) + Right (ExitFailure _, _, err) -> return (Left ("failed to inspect archive entries:\n" ++ err)) + Right (ExitSuccess, typeOut, _) -> do + let badTypes = filter (not . safeArtifactTypeLine) (filter (not . null) (lines typeOut)) + if null badTypes + then return (Right ()) + else return (Left ("unsupported archive entry type " ++ take 1 (head badTypes))) + + safeArtifactPath p = + not (isAbsolute p) && + ".." `notElem` splitDirectories p && + (p == Artifact.artifactManifestFile || + p == "Build.act" || + p == "out" || + p == "out/types" || + "out/types/" `isPrefixOf` p) + + safeArtifactTypeLine [] = True + safeArtifactTypeLine (c:_) = c == '-' || c == 'd' + + runProcessCapture exe args mcwd = do + let cmd = (proc exe args){ cwd = mcwd } + try (readCreateProcessWithExitCode cmd "") :: IO (Either SomeException (ExitCode, String, String)) + + cleanupTmp dir = + removePathForcibly dir `catch` ignoreCleanup + + ignoreCleanup :: IOException -> IO () + ignoreCleanup _ = return () + fetchOne kind name url mh cacheDir zigExe globalCache = do case mh of Just h -> do @@ -4054,6 +4332,7 @@ collectDepTypePaths projDir overrides = do ++ "(directory with src/ and Build.act).") _ -> return (seen, fpMap, acc) else do + validateDependencyPath depName dep depAbs (depPath, fpMap') <- canonicalizeDep depAbs fpMap let typesDir = joinPath [depPath, "out", "types"] if Data.Set.member depPath seen diff --git a/compiler/lib/test/ActonSpec.hs b/compiler/lib/test/ActonSpec.hs index a1757dede..8e985a770 100644 --- a/compiler/lib/test/ActonSpec.hs +++ b/compiler/lib/test/ActonSpec.hs @@ -24,7 +24,10 @@ import qualified Acton.Boxing import qualified Acton.CodeGen import qualified Acton.Diagnostics as Diag import qualified Acton.Compile as Compile +import qualified Acton.CommandLineParser as C +import qualified Acton.Artifact as Artifact import qualified Acton.Fingerprint as Fingerprint +import qualified Acton.SourceProvider as Source import qualified Acton.Completion as Completion import qualified InterfaceFiles import Pretty (print, prettyText) @@ -55,6 +58,7 @@ import qualified Data.Binary as Binary import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Char8 as B8 import qualified Data.Aeson as Ae +import qualified Data.Map as M import qualified System.IO.Unsafe @@ -66,6 +70,81 @@ main = do env0 <- Acton.Env.initEnv sysTypesPath False sydTest $ do + describe "Artifacts" $ do + it "derives default OCI refs from GitHub repo URLs" $ do + Artifact.deriveOciRef "https://github.com/actonlang/acton-yang" "1220abcdef" + `shouldBe` Just ("oci://ghcr.io/actonlang/acton-yang/acton-out:1220abcdef-iface" ++ Artifact.currentInterfaceVersionText) + + it "derives OCI refs from explicit artifact repositories" $ do + Artifact.ociRefForRepository "local-registry.local-domain.com/acton-out" "1220abcdef" + `shouldBe` Just ("oci://local-registry.local-domain.com/acton-out:1220abcdef-iface" ++ Artifact.currentInterfaceVersionText) + + it "derives OCI layout refs from local artifact repositories" $ do + let ref = "oci-layout:///tmp/acton-out:1220abcdef-iface" ++ Artifact.currentInterfaceVersionText + Artifact.ociRefForRepository "/tmp/acton-out" "1220abcdef" `shouldBe` Just ref + Artifact.ociRefIsLocal ref `shouldBe` True + Artifact.ociRefOrasOptions ref `shouldBe` ["--oci-layout"] + Artifact.ociRefOrasTarget ref `shouldBe` "/tmp/acton-out:1220abcdef-iface" ++ Artifact.currentInterfaceVersionText + + it "validates manifests against source hash and interface version" $ do + let manifest = Artifact.expectedManifest "1220abcdef" + Artifact.validateManifest "1220abcdef" manifest `shouldBe` Right () + Artifact.validateManifest "other" manifest + `shouldBe` Left "artifact source hash 1220abcdef does not match dependency source hash other" + + it "enumerates prebuilt artifact modules from .ty files" $ do + withSystemTempDirectory "acton-artifact-root" $ \dir -> do + let mn = S.modName ["prebuilt", "mod"] + tyFile = dir "out" "types" "prebuilt" "mod.ty" + spec = BuildSpec.BuildSpec "prebuilt" Nothing "0x1234abcd5678ef00" M.empty M.empty + ctx = Compile.ProjCtx + { Compile.projRoot = dir + , Compile.projOutDir = dir "out" + , Compile.projTypesDir = dir "out" "types" + , Compile.projSrcDir = dir "src" + , Compile.projSysPath = dir + , Compile.projSysTypes = dir "sys-types" + , Compile.projBuildSpec = spec + , Compile.projDeps = [] + , Compile.projPrebuilt = True + } + gopts = C.GlobalOptions C.Never True True False False False False 0 + createDirectoryIfMissing True (takeDirectory tyFile) + InterfaceFiles.writeFile tyFile B8.empty B8.empty B8.empty Nothing [] [] [] [] Nothing + (I.NModule [] [] Nothing) + (S.Module mn [] Nothing []) + Artifact.writeManifest dir (Artifact.expectedManifest "1220abcdef") + mods <- Compile.enumerateProjectModules ctx + mods `shouldBe` [(tyFile, mn)] + (tasks, _) <- Compile.buildGlobalTasks Source.diskSourceProvider gopts Compile.defaultCompileOptions (M.singleton dir ctx) Nothing + map (Compile.name . Compile.gtTask) tasks `shouldBe` [mn] + + it "keeps source projects in source mode even with artifact files present" $ do + withSystemTempDirectory "acton-source-with-artifact-files" $ \dir -> do + let name = "source_with_artifact_files" + fp = Fingerprint.formatFingerprint + (Fingerprint.updateFingerprintPrefix + (Fingerprint.fingerprintPrefixForName name) 1) + src = dir "src" "foo.act" + gopts = C.GlobalOptions C.Never True True False False False False 0 + createDirectoryIfMissing True (takeDirectory src) + createDirectoryIfMissing True (dir "out" "types") + writeFile (dir "Build.act") $ unlines + [ "name = " ++ show name + , "fingerprint = " ++ fp + , "dependencies = {}" + , "zig_dependencies = {}" + ] + writeFile src "def marker() -> int:\n return 1\n" + Artifact.writeManifest dir (Artifact.expectedManifest "1220abcdef") + projMap <- Compile.discoverProjects gopts dir dir [] + case M.elems projMap of + [ctx] -> do + Compile.projPrebuilt ctx `shouldBe` False + mods <- Compile.enumerateProjectModules ctx + map snd mods `shouldBe` [S.modName ["foo"]] + _ -> expectationFailure "expected one discovered project" + describe "Environment" $ do it "treats mismatched .ty headers for loaded modules as stale" $ do withSystemTempDirectory "acton-env" $ \dir -> do diff --git a/docs/acton-guide/src/SUMMARY.md b/docs/acton-guide/src/SUMMARY.md index 842e08a0e..69dae0766 100644 --- a/docs/acton-guide/src/SUMMARY.md +++ b/docs/acton-guide/src/SUMMARY.md @@ -99,6 +99,7 @@ - [Override Dependency](pkg/override_dependency.md) - [Remove Dependency](pkg/remove_dependency.md) - [Fetch Deps (Airplane mode)](pkg/fetch_dependencies.md) + - [Output Artifacts & Cache](output_artifacts.md) - [Zig / C / C++ dependencies](zig_dependencies.md) - [Security and Trust](security/intro.md) - [Working with Zig / C / C++](working_with_zig.md) diff --git a/docs/acton-guide/src/output_artifacts.md b/docs/acton-guide/src/output_artifacts.md new file mode 100644 index 000000000..ef08e113d --- /dev/null +++ b/docs/acton-guide/src/output_artifacts.md @@ -0,0 +1,157 @@ +# Output Artifacts + +Acton treats source as the canonical form of a package. Dependencies are +identified by the content hash of their source. For large packages, Acton can +use a prebuilt output artifact instead of rebuilding the dependency from +source. If no matching artifact is available, Acton falls back to fetching and +building the source locally. + +Acton uses OCI repositories for artifact distribution. OCI is the image and +artifact format used by container registries. Acton takes advantage of that +registry infrastructure, which is widely available across hosting providers, +CI systems, and private deployments. + +OCI is the transport and storage convention, not the source of artifact +identity. Artifacts are identified by the content hash of the source, the Acton +interface version, and the current target tuple. The source content hash +remains the stable identity; OCI tags are only the lookup convention. + +## Install oras + +Acton uses the `oras` command-line tool to pull and publish OCI artifacts. +Install `oras` and make sure it is on your `PATH` before using output +artifacts. + +A build can still fall back to source when `oras` is not installed. An artifact +that is already available in the local dependency cache can also be used without +contacting any registry. Publishing artifacts requires `oras`. + +## Using artifacts + +At build time, Acton knows the artifact identity it is looking for: the +dependency source content hash, interface version, and target tuple. It searches +OCI repositories for an artifact with that identity. + +Those repositories can be published in two common places. Package authors can +publish artifacts near their source repository, and Acton can derive those +locations for some repository URLs. A team can also run its own artifact +repository as a shared cache, populated from packages that may only publish +source. + +To search a shared artifact repository, pass it during the build: + +```bash +acton build --artifact-repo local-registry.local-domain.com/acton-out +``` + +The artifact repository is the full OCI image name without the tag. The path +can include whatever namespace, group, or organization layout your registry +uses. Acton appends a tag derived from the dependency source content hash and +interface version: + +```text +-iface +``` + +For example, Acton may expand the artifact repository above to: + +```text +oci://local-registry.local-domain.com/acton-out:N-V-__8AAJqWJQA7T6BgHCyrFsXB4bMcBA4_qmIOxqVaL8Vm-iface0.18 +``` + +You can search more than one artifact repository by repeating the option: + +```bash +acton build \ + --artifact-repo local-registry.local-domain.com/acton-out \ + --artifact-repo backup-registry.local-domain.com/platform/cache/acton-out +``` + +Once an artifact has been pulled into the dependency cache, later plain +`acton build` runs reuse it without repeating `--artifact-repo`. + +Acton checks the local dependency cache before contacting any registry. If a +matching artifact is available locally, Acton checks that it matches the +requested source content hash, interface version, and target before using it. If +only a source cache entry exists, Acton uses that entry without probing remote +artifact repositories. + +For local testing or offline workflows, `--artifact-repo` can also point to an +OCI image layout directory: + +```bash +acton build --artifact-repo /tmp/acton-out +``` + +Local OCI layouts are queried before remote repositories and do not require a +registry service. + +For dependency repository URLs on GitHub or GitLab, Acton can derive a +producer-side OCI repository to try automatically: + +- GitHub `https://github.com/OWNER/REPO` maps to + `oci://ghcr.io/OWNER/REPO/acton-out:-iface`. +- GitLab `https://gitlab.com/GROUP/PROJECT` maps to + `oci://registry.gitlab.com/GROUP/PROJECT/acton-out:-iface`. + +If the artifact is missing or incompatible, Acton falls back to the source +package. + +An artifact also carries enough package metadata for the build planner to know +the project name, fingerprint, and transitive dependency graph. + +## Publishing artifacts + +To publish an artifact from a package checkout: + +```bash +acton artifact push --repo-url https://github.com/OWNER/REPO +``` + +This command derives the conventional OCI reference from the repository URL and +uses `oras` to push the artifact. To publish to another registry or local OCI +layout, provide the artifact repository without a tag: + +```bash +acton artifact push --artifact-repo local-registry.local-domain.com/acton-out +acton artifact push --artifact-repo ghcr.io/OWNER/REPO/acton-out +acton artifact push --artifact-repo /tmp/acton-out +``` + +`acton artifact push` builds the current package before packaging and pushing +it. `acton artifact pack` does the same build-and-pack step but only writes the +artifact locally: + +```bash +acton artifact pack +``` + +The artifact is packaged from the local checkout. Acton computes the source +content hash from the package source after the build step, and the artifact tag +is derived from that hash. Consumers need matching source content to use the +artifact. + +To print the hash without building or publishing: + +```bash +acton artifact hash +``` + +## Validation and trust + +The source content hash is the artifact identity. The OCI lookup key is derived +from that identity and Acton's compatibility metadata, so a matching lookup is +expected to return the artifact for that source content. + +Acton still records the same identity inside the artifact and checks it before +use. This is a sanity check against stale uploads, manual registry mistakes, and +incompatible compiler outputs. It is not a separate binary signing scheme. + +Remote artifact pulls use the registry transport, normally HTTPS with TLS. When +Acton derives an artifact repository from a dependency `repo_url`, the registry +namespace follows the source repository owner. For example, a GitHub dependency +maps to `ghcr.io/OWNER/REPO/acton-out`, and a GitLab dependency maps to +`registry.gitlab.com/GROUP/PROJECT/acton-out`. + +For custom `--artifact-repo` values, Acton cannot derive that ownership +relationship. Trust in those repositories has to be established out of band. diff --git a/docs/acton-guide/src/package_management.md b/docs/acton-guide/src/package_management.md index 6cdc367a9..4bc83a1de 100644 --- a/docs/acton-guide/src/package_management.md +++ b/docs/acton-guide/src/package_management.md @@ -11,17 +11,16 @@ same compilation result can be reproduced on another machine. The guiding principle behind Acton's package management is to strive for determinism, robustness, and safety. Dependencies are resolved at design time by -the package developer and written into `Build.act` as archive URLs plus content -hashes. Later builds fetch those recorded archives and verify the hashes; they -do not pick newer compatible versions or rely on a name to mean the same thing -forever. +the package developer and written into `Build.act` as source URLs plus content +hashes. Later builds fetch those recorded source packages and verify the hashes; +they do not pick newer compatible versions or rely on a name to mean the same +thing forever. Acton's public package index is a discovery index. Packages are hosted -by their owners, and dependencies are recorded as URLs from which a -specific package archive can be downloaded. This is typically a tar.gz -or zip archive from GitHub, GitLab, or a similar source hosting site. -The index helps find packages; `Build.act` records the exact archive and -hash used by a project. +by their owners, and dependencies are recorded as URLs from which specific +package source can be downloaded. This is often provided by GitHub, GitLab, or a +similar source hosting site. The index helps find packages; `Build.act` records +the exact source URL and hash used by a project. The public index is decentralized in the sense that package authors opt in from their own GitHub repositories. The index collects repositories @@ -38,6 +37,11 @@ each installed binary and remove those binaries again with All dependencies are fetched and included, linked statically, at compile time, so there are no runtime dependencies. +Large packages can optionally provide prebuilt Acton output artifacts for faster +dependency builds. Source remains canonical; artifacts are a cache and +distribution mechanism for compiler outputs. See +[Output Artifacts](output_artifacts.md) for details. + ## Project lineage fingerprint Each project must declare a **fingerprint** in `Build.act` to represent its lineage — the stable identity of the project across versions. This is separate from dependency content hashes: