├── testdata ├── group1 │ ├── empty.js │ ├── exclude.txt │ ├── exclude │ │ └── example.txt │ ├── example.js │ ├── favicon.ico │ ├── sub │ │ └── heart.gif │ ├── 论雪莱《爱的哲学》中的根隐喻.pdf │ └── index.html └── group2 │ ├── empty.js │ ├── example.js │ └── favicon.ico ├── .gitignore ├── favicon.ico ├── Dockerfile ├── utils ├── env.go ├── timeCost.go ├── action.go ├── error.go ├── hash.go ├── match.go ├── ext.go ├── match_test.go └── walkDir.go ├── .env ├── go.mod ├── .github └── workflows │ ├── dockerhub.yml │ └── test.yml ├── operation ├── website.go ├── incremental.go ├── delete.go └── upload.go ├── main.go ├── config └── config.go ├── go.sum ├── action.yml ├── README.md └── main_test.go /testdata/group1/empty.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/group1/exclude.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/group2/empty.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/group1/exclude/example.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/group1/example.js: -------------------------------------------------------------------------------- 1 | console.log('group1') -------------------------------------------------------------------------------- /testdata/group2/example.js: -------------------------------------------------------------------------------- 1 | console.log('group2') -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | .env.local 4 | .DS_Store -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-website-action/HEAD/favicon.ico -------------------------------------------------------------------------------- /testdata/group1/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-website-action/HEAD/testdata/group1/favicon.ico -------------------------------------------------------------------------------- /testdata/group2/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-website-action/HEAD/testdata/group2/favicon.ico -------------------------------------------------------------------------------- /testdata/group1/sub/heart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-website-action/HEAD/testdata/group1/sub/heart.gif -------------------------------------------------------------------------------- /testdata/group1/论雪莱《爱的哲学》中的根隐喻.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-website-action/HEAD/testdata/group1/论雪莱《爱的哲学》中的根隐喻.pdf -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16 2 | 3 | WORKDIR /go/src/github.com/fangbinwei/aliyun-oss-website-action 4 | COPY . . 5 | RUN go get -d -v ./... 6 | RUN go install -v ./... 7 | 8 | # run in /github/workspace 9 | CMD ["aliyun-oss-website-action"] -------------------------------------------------------------------------------- /utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | // Getenv with fallback 6 | func Getenv(key, fallback string) string { 7 | if value, ok := os.LookupEnv(key); ok { 8 | return value 9 | } 10 | return fallback 11 | } 12 | -------------------------------------------------------------------------------- /testdata/group1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | test 10 | 11 | -------------------------------------------------------------------------------- /utils/timeCost.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | // TimeCost is time-consuming calculation function 8 | // Usage: defer TimeCost()() 9 | func TimeCost() func() { 10 | start := time.Now() 11 | return func() { 12 | tc := time.Since(start) 13 | fmt.Printf("time cost = %v\n", tc) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ENDPOINT = fangbinwei-blog.oss-cn-shanghai.aliyuncs.com 2 | FOLDER = testdata/group1 3 | BUCKET = fangbinwei-blog 4 | INDEX_PAGE = index.html 5 | NOT_FOUND_PAGE = 404.html 6 | CNAME = true 7 | HTML_CACHE_CONTROL = no-cache 8 | IMAGE_CACHE_CONTROL = max-age=864000 9 | OTHER_CACHE_CONTROL = max-age=2592000 10 | SKIP_SETTING = false 11 | INCREMENTAL = true -------------------------------------------------------------------------------- /utils/action.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // GetActionInputAsSlice handle the action multiline input as slice 8 | func GetActionInputAsSlice(input string) []string { 9 | result := make([]string, 0, 5) 10 | s := strings.Split(input, "\n") 11 | for _, i := range s { 12 | if c := strings.TrimSpace(i); c != "" { 13 | result = append(result, c) 14 | } 15 | } 16 | return result 17 | } 18 | -------------------------------------------------------------------------------- /utils/error.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // HandleError is error handling method, print error and exit 9 | func HandleError(err error) { 10 | fmt.Println("occurred error:", err) 11 | os.Exit(1) 12 | } 13 | 14 | // LogErrors is used to print []error 15 | func LogErrors(errs []error) { 16 | if errs != nil { 17 | fmt.Println("Errors:") 18 | for i, err := range errs { 19 | fmt.Printf("%d\n%v\n", i, err) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module aliyun-oss-website-action 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect 7 | github.com/fangbinwei/aliyun-oss-go-sdk v2.1.5-0.20200802190536-e2778bb3ae43+incompatible 8 | github.com/joho/godotenv v1.3.0 9 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 10 | github.com/satori/go.uuid v1.2.0 // indirect 11 | github.com/stretchr/testify v1.6.1 12 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect 13 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /utils/hash.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/base64" 6 | "io" 7 | "os" 8 | ) 9 | 10 | func HashMD5(filepath string) (string, error) { 11 | f, err := os.Open(filepath) 12 | if err != nil { 13 | // TODO: debug info 14 | return "", err 15 | } 16 | defer f.Close() 17 | return hashMD5(f) 18 | 19 | } 20 | 21 | func hashMD5(f io.Reader) (string, error) { 22 | h := md5.New() 23 | if _, err := io.Copy(h, f); err != nil { 24 | // TODO: debug info 25 | return "", err 26 | } 27 | result := h.Sum(nil) 28 | encoded := base64.StdEncoding.EncodeToString(result) 29 | 30 | return encoded, nil 31 | } 32 | -------------------------------------------------------------------------------- /utils/match.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "path" 5 | "strings" 6 | ) 7 | 8 | // Match path with one of patterns 9 | func Match(patterns []string, ossPath string) bool { 10 | for _, p := range patterns { 11 | if match(p, ossPath) { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | 18 | func match(pattern string, ossPath string) bool { 19 | pattern = strings.TrimPrefix(pattern, "./") 20 | if hasMeta(pattern) { 21 | match, err := path.Match(pattern, ossPath) 22 | if err != nil { 23 | return false 24 | } 25 | return match 26 | } 27 | if !strings.HasPrefix(ossPath, pattern) { 28 | return false 29 | } 30 | 31 | // dir 32 | if strings.HasSuffix(pattern, "/") { 33 | return true 34 | } 35 | // file 36 | return ossPath == pattern 37 | } 38 | 39 | func hasMeta(p string) bool { 40 | return strings.IndexAny(p, "*?[") >= 0 41 | } 42 | -------------------------------------------------------------------------------- /utils/ext.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "path" 5 | "strings" 6 | ) 7 | 8 | // IsHTML is used to determine if a file is HTML 9 | func IsHTML(filename string) bool { 10 | return strings.HasSuffix(strings.ToLower(filename), ".html") 11 | } 12 | 13 | // IsPDF is used to determine if a file is PDF 14 | func IsPDF(filename string) bool { 15 | return strings.HasSuffix(strings.ToLower(filename), ".pdf") 16 | } 17 | 18 | // IsImage is used to determine if a file is image 19 | func IsImage(filename string) bool { 20 | imageExts := []string{ 21 | ".png", 22 | ".jpg", 23 | ".jpeg", 24 | ".webp", 25 | ".gif", 26 | ".bmp", 27 | ".tiff", 28 | ".ico", 29 | ".svg", 30 | } 31 | return func() bool { 32 | ext := path.Ext(filename) 33 | for _, e := range imageExts { 34 | if e == ext { 35 | return true 36 | } 37 | } 38 | return false 39 | }() 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: docker build&upload 2 | 3 | on: 4 | # 支持手动触发 5 | workflow_dispatch: 6 | inputs: 7 | tag: 8 | default: 'latest' 9 | description: dockerhub tag 10 | push: 11 | branches: 12 | - 'master' 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v2 21 | - 22 | name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | - 25 | name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | - 28 | name: Login to DockerHub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKERHUB_USERNAME }} 32 | password: ${{ secrets.DOCKERHUB_TOKEN }} 33 | - 34 | name: Build and push 35 | uses: docker/build-push-action@v2 36 | with: 37 | context: . 38 | push: true 39 | tags: ${{ format('fangbinwei/aliyun-oss-website-action:{0}', github.event.inputs.tag || 'latest') }} 40 | -------------------------------------------------------------------------------- /utils/match_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | type mock struct { 10 | pattern string 11 | ossPath string 12 | expect bool 13 | } 14 | 15 | func TestMatch(t *testing.T) { 16 | assert := assert.New(t) 17 | 18 | matches := []mock{ 19 | {"dir/", "dir", false}, 20 | {"dir/", "dir/sub", true}, 21 | {"./dir/", "dir/file", true}, 22 | {"dir/", "dir/dir2/file", true}, 23 | {"dir2/", "dir/dir2/file", false}, 24 | 25 | {"file", "file", true}, 26 | {"file", "file1", false}, 27 | {"file*", "file1", true}, 28 | {"file", "file/", false}, 29 | {"file", "dir/file", false}, 30 | {"dir/*.js", "dir/file.js", true}, 31 | {"dir*/", "dir1/", true}, 32 | {"dir/*.js", "dir/file.css", false}, 33 | {"dir/*.js", "dir/dir2/file.js", false}, 34 | {"dir/*/*.js", "dir/dir2/file.js", true}, 35 | {"dir/**/*.js", "dir/dir2/dir3/file.js", false}, 36 | } 37 | 38 | for _, item := range matches { 39 | if item.expect { 40 | assert.True(match(item.pattern, item.ossPath), item) 41 | } else { 42 | assert.False(match(item.pattern, item.ossPath), item) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /operation/website.go: -------------------------------------------------------------------------------- 1 | package operation 2 | 3 | import ( 4 | "fmt" 5 | 6 | "aliyun-oss-website-action/config" 7 | 8 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 9 | ) 10 | 11 | // SetStaticWebsiteConfig is used to set some option of website, like redirect strategy, index page, 404 page. 12 | func SetStaticWebsiteConfig() error { 13 | bEnable := true 14 | supportSubDirType := 0 15 | websiteDetailConfig, err := config.Client.GetBucketWebsite(config.Bucket.BucketName) 16 | if err != nil { 17 | serviceError, ok := err.(oss.ServiceError) 18 | // 404 means NoSuchWebsiteConfiguration 19 | if !ok || serviceError.StatusCode != 404 { 20 | fmt.Println("Failed to get website detail configuration, skip setting", err) 21 | return err 22 | } 23 | } 24 | wxml := oss.WebsiteXML(websiteDetailConfig) 25 | wxml.IndexDocument.Suffix = config.IndexPage 26 | wxml.ErrorDocument.Key = config.NotFoundPage 27 | wxml.IndexDocument.SupportSubDir = &bEnable 28 | wxml.IndexDocument.Type = &supportSubDirType 29 | 30 | err = config.Client.SetBucketWebsiteDetail(config.BucketName, wxml) 31 | if err != nil { 32 | fmt.Printf("Failed to set website detail configuration: %v\n", err) 33 | return err 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /utils/walkDir.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | ) 10 | 11 | var sema = make(chan struct{}, 20) 12 | 13 | // FileInfoType is a type which contains dir and os.FileInfo 14 | type FileInfoType struct { 15 | Dir string 16 | Path string 17 | PathOSS string 18 | Name string 19 | CacheControl string // Complete 'CacheControl' when uploading files 20 | ContentMD5 string 21 | ValidHash bool // if ContentMD5 is valid 22 | } 23 | 24 | // WalkDir get sub files of target dir 25 | func WalkDir(root string) <-chan FileInfoType { 26 | fileInfos := make(chan FileInfoType, 100) 27 | var sw sync.WaitGroup 28 | sw.Add(1) 29 | go func() { 30 | walkDir(root, &sw, fileInfos) 31 | }() 32 | go func() { 33 | sw.Wait() 34 | close(fileInfos) 35 | }() 36 | return fileInfos 37 | } 38 | 39 | func walkDir(dir string, sw *sync.WaitGroup, fileInfos chan<- FileInfoType) { 40 | defer sw.Done() 41 | for _, entry := range dirents(dir) { 42 | entryName := entry.Name() 43 | if entry.IsDir() { 44 | sw.Add(1) 45 | subdir := filepath.Join(dir, entryName) 46 | go walkDir(subdir, sw, fileInfos) 47 | } else { 48 | p := filepath.Join(dir, entryName) 49 | contentMD5, _ := HashMD5(p) 50 | fileInfos <- FileInfoType{ 51 | ValidHash: contentMD5 != "", 52 | ContentMD5: contentMD5, 53 | Dir: dir, 54 | Path: p, 55 | PathOSS: filepath.ToSlash(p), 56 | Name: entryName, 57 | } 58 | } 59 | } 60 | } 61 | 62 | func dirents(dir string) []os.FileInfo { 63 | sema <- struct{}{} // acquire token 64 | defer func() { <-sema }() // release token 65 | 66 | // TOOD: use os.ReadDir 67 | entries, err := ioutil.ReadDir(dir) 68 | if err != nil { 69 | fmt.Printf("dirents error: %v\n", err) 70 | return nil 71 | } 72 | return entries 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "aliyun-oss-website-action/config" 8 | "aliyun-oss-website-action/operation" 9 | "aliyun-oss-website-action/utils" 10 | ) 11 | 12 | func main() { 13 | defer utils.TimeCost()() 14 | if config.Folder == "/" { 15 | fmt.Println("You should not upload the root directory, use ./ instead. 通常来说, 你不应该上传根目录, 也许你是要配置 ./") 16 | os.Exit(1) 17 | } 18 | 19 | if !config.SkipSetting { 20 | operation.SetStaticWebsiteConfig() 21 | } else { 22 | fmt.Println("skip setting static pages related configuration") 23 | } 24 | 25 | var incremental *operation.IncrementalConfig 26 | if config.IsIncremental { 27 | fmt.Println("---- [incremental] ---->") 28 | incremental, _ = operation.GetRemoteIncrementalConfig(config.Bucket) 29 | fmt.Println("<---- [incremental end] ----") 30 | fmt.Println() 31 | } 32 | if !config.IsIncremental || incremental == nil { 33 | // TODO: delete after upload 34 | fmt.Println("---- [delete] ---->") 35 | deleteErrs := operation.DeleteObjects(config.Bucket) 36 | utils.LogErrors(deleteErrs) 37 | fmt.Println("<---- [delete end] ----") 38 | fmt.Println() 39 | } 40 | 41 | records := utils.WalkDir(config.Folder) 42 | 43 | fmt.Println("---- [upload] ---->") 44 | uploaded, uploadErrs := operation.UploadObjects(config.Folder, config.Bucket, records, incremental) 45 | utils.LogErrors(uploadErrs) 46 | fmt.Println("<---- [upload end] ----") 47 | fmt.Println() 48 | 49 | if config.IsIncremental && incremental != nil { 50 | fmt.Println("---- [delete] ---->") 51 | deleteErrs := operation.DeleteObjectsIncremental(config.Bucket, incremental) 52 | utils.LogErrors(deleteErrs) 53 | fmt.Println("<---- [delete end] ----") 54 | fmt.Println() 55 | } 56 | 57 | if config.IsIncremental { 58 | fmt.Println("---- [incremental] ---->") 59 | operation.UploadIncrementalConfig(config.Bucket, uploaded) 60 | fmt.Println("<---- [incremental end] ----") 61 | fmt.Println() 62 | } 63 | 64 | if len(uploadErrs) > 0 { 65 | os.Exit(1) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: deploy test 2 | 3 | on: 4 | # 支持手动触发 5 | workflow_dispatch: 6 | pull_request: 7 | push: 8 | branches: 9 | - 'master' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [14.x] 18 | 19 | steps: 20 | # load repo to /github/workspace 21 | - uses: actions/checkout@v2 22 | with: 23 | repository: fangbinwei/blog 24 | fetch-depth: 0 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm install yarn@1.22.4 -g 30 | 31 | - name: Get yarn cache directory path 32 | id: yarn-cache-dir-path 33 | run: echo "::set-output name=dir::$(yarn cache dir)" 34 | 35 | - uses: actions/cache@v2 36 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 37 | with: 38 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 39 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-yarn- 42 | - run: yarn install 43 | - run: yarn docs:build 44 | - name: upload files to OSS 45 | uses: fangbinwei/aliyun-oss-website-action@master 46 | with: 47 | accessKeyId: ${{ secrets.ACCESS_KEY_ID }} 48 | accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }} 49 | bucket: fangbinwei-blog 50 | endpoint: b.fangbinwei.cn 51 | cname: true 52 | # folder in /github/workspace 53 | folder: .vuepress/dist 54 | htmlCacheControl: no-cache 55 | imageCacheControl: max-age=864001 56 | otherCacheControl: max-age=2592001 57 | pdfCacheControl: no-cache 58 | skipSetting: false 59 | # not support recursive pattern ** 60 | exclude: | 61 | CNAME 62 | demo1/ 63 | demo2/*.md 64 | demo2/*/*.md -------------------------------------------------------------------------------- /operation/incremental.go: -------------------------------------------------------------------------------- 1 | package operation 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "sync" 9 | 10 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 11 | ) 12 | 13 | const INCREMENTAL_CONFIG = ".actioninfo" 14 | 15 | type IncrementalConfig struct { 16 | sync.RWMutex 17 | M map[string]struct { 18 | ContentMD5 string 19 | CacheControl string 20 | } 21 | } 22 | 23 | func (i *IncrementalConfig) stringify() ([]byte, error) { 24 | j, err := json.Marshal(i.M) 25 | return j, err 26 | } 27 | 28 | func (i *IncrementalConfig) parse(raw []byte) error { 29 | err := json.Unmarshal(raw, &(i.M)) 30 | return err 31 | } 32 | 33 | func generateIncrementalConfig(uploaded []UploadedObject) ([]byte, error) { 34 | i := new(IncrementalConfig) 35 | i.M = make(map[string]struct { 36 | ContentMD5 string 37 | CacheControl string 38 | }) 39 | for _, u := range uploaded { 40 | if !u.ValidHash { 41 | continue 42 | } 43 | i.M[u.ObjectKey] = struct { 44 | ContentMD5 string 45 | CacheControl string 46 | }{ 47 | ContentMD5: u.ContentMD5, 48 | CacheControl: u.CacheControl, 49 | } 50 | } 51 | j, err := i.stringify() 52 | return j, err 53 | 54 | } 55 | 56 | func UploadIncrementalConfig(bucket *oss.Bucket, records []UploadedObject) error { 57 | j, err := generateIncrementalConfig(records) 58 | if err != nil { 59 | fmt.Printf("Failed to generate incremental info: %v\n", err) 60 | return err 61 | } 62 | 63 | options := []oss.Option{ 64 | oss.ObjectACL(oss.ACLPrivate), 65 | } 66 | err = bucket.PutObject(INCREMENTAL_CONFIG, bytes.NewReader(j), options...) 67 | if err != nil { 68 | fmt.Printf("Failed to upload incremental info: %v\n", err) 69 | return err 70 | } 71 | 72 | fmt.Printf("Update & Upload incremental info: %s\n", INCREMENTAL_CONFIG) 73 | return nil 74 | } 75 | 76 | func GetRemoteIncrementalConfig(bucket *oss.Bucket) (*IncrementalConfig, error) { 77 | c := new(bytes.Buffer) 78 | body, err := bucket.GetObject(INCREMENTAL_CONFIG) 79 | if err != nil { 80 | fmt.Printf("Failed to get remote incremental info: %v\n", err) 81 | return nil, err 82 | } 83 | io.Copy(c, body) 84 | body.Close() 85 | i := new(IncrementalConfig) 86 | err = i.parse(c.Bytes()) 87 | if err != nil { 88 | fmt.Printf("Failed to parse remote incremental info: %v\n", err) 89 | return nil, err 90 | } 91 | fmt.Printf("Get remote incremental info: %s\n", INCREMENTAL_CONFIG) 92 | 93 | return i, nil 94 | } 95 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "aliyun-oss-website-action/utils" 8 | 9 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | var ( 14 | Endpoint string 15 | AccessKeyID string 16 | AccessKeySecret string 17 | Folder string 18 | Exclude []string 19 | BucketName string 20 | IsCname bool 21 | Client *oss.Client 22 | Bucket *oss.Bucket 23 | SkipSetting bool 24 | IsIncremental bool 25 | 26 | IndexPage string 27 | NotFoundPage string 28 | HTMLCacheControl string 29 | ImageCacheControl string 30 | OtherCacheControl string 31 | PDFCacheControl string 32 | ) 33 | 34 | func init() { 35 | godotenv.Load(".env") 36 | godotenv.Load(".env.local") 37 | 38 | Endpoint = os.Getenv("ENDPOINT") 39 | IsCname = os.Getenv("CNAME") == "true" 40 | AccessKeyID = os.Getenv("ACCESS_KEY_ID") 41 | AccessKeySecret = os.Getenv("ACCESS_KEY_SECRET") 42 | Folder = os.Getenv("FOLDER") 43 | Exclude = utils.GetActionInputAsSlice(os.Getenv("EXCLUDE")) 44 | BucketName = os.Getenv("BUCKET") 45 | SkipSetting = os.Getenv("SKIP_SETTING") == "true" 46 | IsIncremental = os.Getenv("INCREMENTAL") == "true" 47 | 48 | IndexPage = utils.Getenv("INDEX_PAGE", "index.html") 49 | NotFoundPage = utils.Getenv("NOT_FOUND_PAGE", "404.html") 50 | HTMLCacheControl = utils.Getenv("HTML_CACHE_CONTROL", "no-cache") 51 | ImageCacheControl = utils.Getenv("IMAGE_CACHE_CONTROL", "max-age=864000") 52 | OtherCacheControl = utils.Getenv("OTHER_CACHE_CONTROL", "max-age=2592000") 53 | PDFCacheControl = utils.Getenv("PDF_CACHE_CONTROL", "max-age=2592000") 54 | 55 | currentPath, err := os.Getwd() 56 | if err != nil { 57 | fmt.Println(err) 58 | } 59 | fmt.Printf("current directory: %s\n", currentPath) 60 | fmt.Printf("endpoint: %s\nbucketName: %s\nfolder: %s\nincremental: %t\nexclude: %v\nindexPage: %s\nnotFoundPage: %s\nisCname: %t\nskipSetting: %t\n", 61 | Endpoint, BucketName, Folder, IsIncremental, Exclude, IndexPage, NotFoundPage, IsCname, SkipSetting) 62 | fmt.Printf("HTMLCacheControl: %s\nimageCacheControl: %s\notherCacheControl: %s\npdfCacheControl: %s\n", 63 | HTMLCacheControl, ImageCacheControl, OtherCacheControl, PDFCacheControl) 64 | 65 | Client, err = oss.New(Endpoint, AccessKeyID, AccessKeySecret, oss.UseCname(IsCname)) 66 | if err != nil { 67 | utils.HandleError(err) 68 | } 69 | 70 | Bucket, err = Client.Bucket(BucketName) 71 | if err != nil { 72 | utils.HandleError(err) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f h1:ZNv7On9kyUzm7fvRZumSyy/IUiSC7AzL0I1jKKtwooA= 2 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fangbinwei/aliyun-oss-go-sdk v2.1.5-0.20200802190536-e2778bb3ae43+incompatible h1:kwCQtEg2LSb0SYlB8OKqg6faDL7MS6lSvEM0cg705XY= 6 | github.com/fangbinwei/aliyun-oss-go-sdk v2.1.5-0.20200802190536-e2778bb3ae43+incompatible/go.mod h1:ugoCgcIlrHXbCHuMbJ8Iq5RGBxrLuP2apphXWDlesY0= 7 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 8 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 9 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 10 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 12 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 13 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 17 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 19 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 20 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 21 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= 22 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 25 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 27 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'aliyun-oss-website-action' 2 | description: 'Deploy your static website on aliyun OSS' 3 | author: 'Binwei Fang ' 4 | branding: 5 | icon: 'upload' 6 | color: 'gray-dark' 7 | inputs: 8 | folder: 9 | description: 'Folder which contains the website files' 10 | required: true 11 | exclude: 12 | description: 'Exclude file from the folder' 13 | required: false 14 | accessKeyId: 15 | description: 'Aliyun OSS accessKeyId.' 16 | required: true 17 | accessKeySecret: 18 | description: 'Aliyun OSS accessKeySecret.' 19 | required: true 20 | bucket: 21 | description: 'Aliyun OSS bucket instance.' 22 | required: true 23 | endpoint: 24 | description: 'OSS region domain' 25 | required: true 26 | cname: 27 | description: '`true` to identify the endpoint is your custom domain.' 28 | required: false 29 | default: 'false' 30 | skipSetting: 31 | description: '`true` to skip setting static pages related configuration. `indexPage`, `notFoundPage` will not be used.' 32 | required: false 33 | default: 'false' 34 | incremental: 35 | description: 'Save info of uploaded files to increase next upload speed' 36 | required: false 37 | default: 'true' 38 | indexPage: 39 | description: 'index page' 40 | required: false 41 | default: 'index.html' 42 | notFoundPage: 43 | description: 'not found page' 44 | required: false 45 | default: '404.html' 46 | htmlCacheControl: 47 | description: 'Cache-Control for HTML' 48 | required: false 49 | default: 'no-cache' 50 | imageCacheControl: 51 | description: 'Cache-Control for image' 52 | required: false 53 | default: 'max-age=864000' 54 | pdfCacheControl: 55 | description: 'Cache-Control for PDF' 56 | required: false 57 | default: 'max-age=2592000' 58 | otherCacheControl: 59 | description: 'Cache-Control for other files' 60 | required: false 61 | default: 'max-age=2592000' 62 | runs: 63 | using: 'docker' 64 | image: 'Dockerfile' 65 | env: 66 | ACCESS_KEY_ID: ${{ inputs.accessKeyId }} 67 | ACCESS_KEY_SECRET: ${{ inputs.accessKeySecret }} 68 | BUCKET: ${{ inputs.bucket }} 69 | ENDPOINT: ${{ inputs.endpoint }} 70 | CNAME: ${{ inputs.cname }} 71 | FOLDER: ${{ inputs.folder }} 72 | EXCLUDE: ${{ inputs.exclude }} 73 | SKIP_SETTING: ${{ inputs.skipSetting }} 74 | INCREMENTAL: ${{ inputs.incremental }} 75 | INDEX_PAGE: ${{ inputs.indexPage }} 76 | NOT_FOUND_PAGE: ${{ inputs.notFoundPage }} 77 | HTML_CACHE_CONTROL: ${{ inputs.htmlCacheControl }} 78 | IMAGE_CACHE_CONTROL: ${{ inputs.imageCacheControl }} 79 | OTHER_CACHE_CONTROL: ${{ inputs.otherCacheControl }} 80 | PDF_CACHE_CONTROL: ${{ inputs.pdfCacheControl }} 81 | 82 | 83 | -------------------------------------------------------------------------------- /operation/delete.go: -------------------------------------------------------------------------------- 1 | package operation 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "aliyun-oss-website-action/utils" 8 | 9 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 10 | ) 11 | 12 | const maxKeys = 100 13 | 14 | // DeleteObjects is used to delete all objects of the bucket 15 | func DeleteObjects(bucket *oss.Bucket) []error { 16 | var errs []error 17 | objKeyCollection := make(chan string, maxKeys) 18 | go listObjects(bucket, objKeyCollection) 19 | 20 | var sw sync.WaitGroup 21 | var mutex sync.Mutex 22 | tokens := make(chan struct{}, 10) 23 | for k := range objKeyCollection { 24 | sw.Add(1) 25 | go func(key string) { 26 | defer sw.Done() 27 | defer func() { 28 | <-tokens 29 | }() 30 | tokens <- struct{}{} 31 | err := deleteObject(bucket, key) 32 | if err != nil { 33 | mutex.Lock() 34 | errs = append(errs, fmt.Errorf("[FAILED] objectKey: %s\nDetail: %v", key, err)) 35 | mutex.Unlock() 36 | return 37 | } 38 | fmt.Printf("objectKey: %s\n", key) 39 | }(k) 40 | } 41 | sw.Wait() 42 | 43 | if len(errs) > 0 { 44 | return errs 45 | } 46 | return nil 47 | } 48 | 49 | func DeleteObjectsIncremental(bucket *oss.Bucket, i *IncrementalConfig) []error { 50 | if i == nil { 51 | return nil 52 | } 53 | // delete incremental info 54 | i.M[INCREMENTAL_CONFIG] = struct { 55 | ContentMD5 string 56 | CacheControl string 57 | }{} 58 | 59 | // TODO: optimize 60 | var errs []error 61 | 62 | var sw sync.WaitGroup 63 | var mutex sync.Mutex 64 | tokens := make(chan struct{}, 10) 65 | for k := range i.M { 66 | sw.Add(1) 67 | go func(key string) { 68 | defer sw.Done() 69 | defer func() { 70 | <-tokens 71 | }() 72 | tokens <- struct{}{} 73 | err := deleteObject(bucket, key) 74 | if err != nil { 75 | mutex.Lock() 76 | errs = append(errs, fmt.Errorf("[FAILED] objectKey: %s\nDetail: %v", key, err)) 77 | mutex.Unlock() 78 | return 79 | } 80 | fmt.Printf("objectKey: %s\n", key) 81 | }(k) 82 | } 83 | sw.Wait() 84 | 85 | if len(errs) > 0 { 86 | return errs 87 | } 88 | return nil 89 | } 90 | 91 | func deleteObject(bucket *oss.Bucket, key string) error { 92 | err := bucket.DeleteObject(key) 93 | if err != nil { 94 | return err 95 | } 96 | return nil 97 | } 98 | 99 | func listObjects(bucket *oss.Bucket, objKeyCollection chan<- string) { 100 | marker := oss.Marker("") 101 | for { 102 | lor, err := bucket.ListObjects(oss.MaxKeys(maxKeys), marker) 103 | if err != nil { 104 | utils.HandleError(err) 105 | } 106 | for _, object := range lor.Objects { 107 | objKeyCollection <- object.Key 108 | } 109 | marker = oss.Marker(lor.NextMarker) 110 | if !lor.IsTruncated { 111 | close(objKeyCollection) 112 | break 113 | } 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /operation/upload.go: -------------------------------------------------------------------------------- 1 | package operation 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | "sync" 9 | 10 | "aliyun-oss-website-action/config" 11 | "aliyun-oss-website-action/utils" 12 | 13 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 14 | ) 15 | 16 | type UploadedObject struct { 17 | ObjectKey string 18 | Incremental bool 19 | utils.FileInfoType 20 | } 21 | 22 | // UploadObjects upload files to OSS 23 | func UploadObjects(root string, bucket *oss.Bucket, records <-chan utils.FileInfoType, i *IncrementalConfig) ([]UploadedObject, []error) { 24 | if root == "/" { 25 | fmt.Println("You should not upload the root directory, use ./ instead. 通常来说, 你不应该上传根目录, 也许你是要配置 ./") 26 | os.Exit(1) 27 | } else { 28 | root = path.Clean(root) + "/" 29 | } 30 | 31 | var sw sync.WaitGroup 32 | var errorMutex sync.Mutex 33 | var uploadedMutex sync.Mutex 34 | var errs []error 35 | uploaded := make([]UploadedObject, 0, 20) 36 | var tokens = make(chan struct{}, 30) 37 | for item := range records { 38 | sw.Add(1) 39 | go func(item utils.FileInfoType) { 40 | defer sw.Done() 41 | fPath := item.Path 42 | objectKey := strings.TrimPrefix(item.PathOSS, root) 43 | options := getHTTPHeader(&item) 44 | 45 | if shouldExclude(objectKey) { 46 | fmt.Printf("[EXCLUDE] objectKey: %s\n\n", objectKey) 47 | return 48 | } 49 | if shouldSkip(item, objectKey, i) { 50 | fmt.Printf("[SKIP] objectKey: %s \n\n", objectKey) 51 | uploadedMutex.Lock() 52 | uploaded = append(uploaded, UploadedObject{ObjectKey: objectKey, Incremental: true, FileInfoType: item}) 53 | uploadedMutex.Unlock() 54 | return 55 | } 56 | 57 | tokens <- struct{}{} 58 | err := bucket.PutObjectFromFile(objectKey, fPath, options...) 59 | <-tokens 60 | if err != nil { 61 | errorMutex.Lock() 62 | errs = append(errs, fmt.Errorf("[FAILED] objectKey: %s\nfilePath: %s\nDetail: %v", objectKey, fPath, err)) 63 | errorMutex.Unlock() 64 | return 65 | } 66 | fmt.Printf("objectKey: %s\nfilePath: %s\n\n", objectKey, fPath) 67 | uploadedMutex.Lock() 68 | uploaded = append(uploaded, UploadedObject{ObjectKey: objectKey, FileInfoType: item}) 69 | uploadedMutex.Unlock() 70 | }(item) 71 | } 72 | sw.Wait() 73 | if len(errs) > 0 { 74 | return uploaded, errs 75 | } 76 | return uploaded, nil 77 | } 78 | 79 | func getHTTPHeader(item *utils.FileInfoType) []oss.Option { 80 | return []oss.Option{ 81 | getCacheControlOption(item), 82 | } 83 | } 84 | 85 | func getCacheControlOption(item *utils.FileInfoType) oss.Option { 86 | var value string 87 | filename := item.Name 88 | 89 | if utils.IsHTML(filename) { 90 | value = config.HTMLCacheControl 91 | } else if utils.IsImage(filename) { 92 | // pic name may not contains hash, so use different strategy 93 | // 10 days 94 | value = config.ImageCacheControl 95 | } else if utils.IsPDF(filename) { 96 | value = config.PDFCacheControl 97 | } else { 98 | // static assets like .js .css, use contentHash in file name, so html can update these files. 99 | // 30 days 100 | value = config.OtherCacheControl 101 | } 102 | item.CacheControl = value 103 | return oss.CacheControl(value) 104 | } 105 | 106 | func shouldExclude(objectKey string) bool { 107 | if utils.Match(config.Exclude, objectKey) { 108 | return true 109 | } 110 | return false 111 | } 112 | 113 | func shouldSkip(item utils.FileInfoType, objectKey string, i *IncrementalConfig) bool { 114 | if i == nil { 115 | return false 116 | } 117 | i.RLock() 118 | remoteConfig, ok := i.M[objectKey] 119 | i.RUnlock() 120 | if !ok { 121 | return false 122 | } 123 | // delete existed objectKey in incremental map, the left is what we should delete 124 | i.Lock() 125 | delete(i.M, objectKey) 126 | i.Unlock() 127 | if item.ValidHash && item.ContentMD5 == remoteConfig.ContentMD5 && item.CacheControl == remoteConfig.CacheControl { 128 | return true 129 | } 130 | return false 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aliyun-oss-website-action 2 | 3 | deploy website on aliyun OSS(Alibaba Cloud OSS) 4 | 5 | 将静态网站部署在阿里云OSS 6 | 7 | ## 概览 8 | - 在阿里云OSS创建一个存放网站的bucket 9 | - 准备一个域名, 可能需要备案(bucket选择非大陆区域, 可以不备案, 但是如果CDN加速区域包括大陆, 仍然需要备案) 10 | - 在你的网站repo中, 配置github action, action 触发则**增量上传**网站repo生成的资源文件到bucket中 11 | - 通过阿里云OSS的CDN, 可以很方便地加速网站的访问, 支持HTTPS 12 | > 阿里云HTTPS免费证书停止自动续签, 但是可以自己[申请免费的证书](https://help.aliyun.com/document_detail/156645.htm), 具体解决方案参考[该公告](https://help.aliyun.com/document_detail/479351.html) 13 | 14 | ## Usage 15 | 16 | ```yml 17 | - name: upload files to OSS 18 | uses: fangbinwei/aliyun-oss-website-action@v1 19 | with: 20 | accessKeyId: ${{ secrets.ACCESS_KEY_ID }} 21 | accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }} 22 | bucket: your-bucket-name 23 | # use your own endpoint 24 | endpoint: oss-cn-shanghai.aliyuncs.com 25 | folder: your-website-output-folder 26 | ``` 27 | > 如果你使用了environment secret请[查看这里](#配置了environment-secret怎么不生效) 28 | ### 配置项 29 | - `accessKeyId`: **必填** 30 | - `accessKeySecret`: **必填** 31 | - `endpoint`: **必填**, 支持指定protocol, 例如`https://example.org`或者`http://example.org` 32 | - `folder`: **必填**, repo打包输出的资源文件夹 33 | - `bucket`: **必填**,部署网站的bucket, 用于存放网站的资源 34 | - `indexPage`: 默认`index.html`.网站首页(用于[静态页面配置](#静态页面配置)) 35 | - `notFoundPage`: 默认`404.html`.网站404页面(用于[静态页面配置](#静态页面配置)) 36 | - `incremental`: 默认`true`. 使用增量上传. 37 | - `skipSetting`: 默认`false`, 是否跳过设置[静态页面配置](#静态页面配置) 38 | - `htmlCacheControl`: 默认`no-cache` 39 | - `imageCacheControl`: 默认`max-age=864000` 40 | - `pdfCacheControl`: 默认`max-age=2592000` 41 | - `otherCacheControl`: 默认`max-age=2592000` 42 | - `exclude`: 不上传`folder`下的某些文件/文件夹 43 | - `cname`: 默认`false`. 若`endpoint`填写自定义域名/bucket域名, 需设置为`true`. (使用CDN的场景下, 不推荐使用自定义域名) 44 | 45 | ## incremental 46 | **开启`incremental`** 47 | 上传文件到OSS后, 还会将文件的`ContentMD5`和`Cache-Control`收集到名为`.actioninfo`的私有文件中. 当再次触发action的时候, 会将待上传的文件信息与`.actioninfo`中记录的信息比对, 信息未发生变化的文件将跳过上传步骤, 只进行增量上传. 且在上传之后, 根据`.actioninfo`和已上传的文件信息, 将OSS中多余的文件进行删除. 48 | 49 | > `.actioninfo` 记录了上一次action执行时, 所上传的文件信息. 私有, 不可公共读写. 50 | 51 | **关闭`incremental`** 或 OSS中不存在`.actioninfo`文件 52 | 53 | 会执行如下步骤 54 | 1. 清除所有OSS中已有的文件 55 | 2. 上传新的文件到OSS中 56 | 57 | > **计划未来优化这个步骤, 优化后, 先上传新的文件到OSS中, 再diff删除多余的文件.** 58 | 59 | ## Cache-Control 60 | 为上传的资源默认设置的`Cache-Control`如下 61 | |资源类型 | Cache-Control| 62 | |----| ----| 63 | |.html|no-cache| 64 | |.png/jpg...(图片资源)|max-age=864000(10days)| 65 | |other|max-age=2592000(30days)| 66 | 67 | ## 静态页面配置 68 | 默认的, action会将阿里云OSS的静态页面配置成如下 69 | ![2020-08-06-03-18-25](https://image.fangbinwei.cn/github/aliyun-oss-website-action/2020-08-06-03-18-25_05d556d8.png) 70 | 71 | 若不需要action来设置, 可以配置`skipSetting`为`true` 72 | 73 | ## exclude 74 | 如果`folder`下的某些文件不需要上传 75 | 76 | 77 | ```yml 78 | - name: exclude some files 79 | uses: fangbinwei/aliyun-oss-website-action@v1 80 | with: 81 | folder: dist 82 | exclude: | 83 | tmp.txt 84 | tmp/ 85 | tmp2/*.txt 86 | tmp2/*/*.txt 87 | # match dist/tmp.txt 88 | # match dist/tmp/ 89 | # match dist/tmp2/a.txt 90 | # match dist/tmp2/a/b.txt, not match dist/tmp2/tmp3/a/b.txt 91 | ``` 92 | > 不支持`**` 93 | 94 | 或者 95 | ```yml 96 | - name: Clean files before upload 97 | run: rm -f dist/tmp.txt 98 | ``` 99 | 100 | ## Docker image 101 | 直接使用已经build好的docker image 102 | ```yml 103 | - name: upload files to OSS 104 | uses: docker://fangbinwei/aliyun-oss-website-action:v1 105 | # 使用env而不是with, 参数可以见本项目的action.yml 106 | env: 107 | ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }} 108 | ACCESS_KEY_SECRET: ${{ secrets.ACCESS_KEY_SECRET }} 109 | BUCKET: your-bucket-name 110 | ENDPOINT: ali-oss-endpoint 111 | FOLDER: your-website-output-folder 112 | ``` 113 | 114 | ## Demo 115 | ### 部署VuePress项目 116 | 117 | ```yml 118 | 119 | name: deploy vuepress 120 | 121 | on: 122 | push: 123 | branches: 124 | - master 125 | 126 | jobs: 127 | build: 128 | 129 | runs-on: ubuntu-latest 130 | steps: 131 | # load repo to /github/workspace 132 | - uses: actions/checkout@v2 133 | with: 134 | repository: fangbinwei/blog 135 | fetch-depth: 0 136 | - name: Use Node.js 137 | uses: actions/setup-node@v1 138 | with: 139 | node-version: '12' 140 | - run: npm install yarn@1.22.4 -g 141 | - run: yarn install 142 | # 打包文档命令 143 | - run: yarn docs:build 144 | - name: upload files to OSS 145 | uses: fangbinwei/aliyun-oss-website-action@v1 146 | with: 147 | accessKeyId: ${{ secrets.ACCESS_KEY_ID }} 148 | accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }} 149 | bucket: "your-bucket-name" 150 | endpoint: "oss-cn-shanghai.aliyuncs.com" 151 | folder: ".vuepress/dist" 152 | ``` 153 | 具体可以参考本项目的[workflow](.github/workflows/test.yml), npm/yarn配合`action/cache`加速依赖安装 154 | 155 | ### Vue 156 | 157 | [see here](https://github.com/fangbinwei/oss-website-demo-spa-vue) 158 | 159 | ```yml 160 | - name: upload files to OSS 161 | uses: fangbinwei/aliyun-oss-website-action@v1 162 | with: 163 | accessKeyId: ${{ secrets.ACCESS_KEY_ID }} 164 | accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }} 165 | bucket: website-spa-vue-demo 166 | endpoint: oss-spa-demo.fangbinwei.cn 167 | cname: true 168 | folder: dist 169 | notFoundPage: index.html 170 | htmlCacheControl: no-cache 171 | imageCacheControl: max-age=864001 172 | otherCacheControl: max-age=2592001 173 | ``` 174 | 175 | ## FAQ 176 | 177 | ### 配合CDN使用时, OSS更新后, CDN未刷新 178 | 179 | 开启OSS提供的CDN缓存自动刷新功能, 将触发操作配置为`PutObject`, `DeleteObject`. 180 | 181 | ![2020-12-13-23-51-28](https://image.fangbinwei.cn/github/aliyun-oss-website-action/2020-12-13-23-51-28_2c310155.png) 182 | 183 | ![2020-12-13-23-51-55](https://image.fangbinwei.cn/github/aliyun-oss-website-action/2020-12-13-23-51-55_5fe79a54.png) 184 | 185 | ### `endpoint`使用自定义域名, 但是无法上传 186 | 1. 如果`endpoint`的域名CNAME记录为阿里云CDN, CDN是否配置了http强制跳转https? 若配置了, 需要在`endpoint`中指定https, 即`endpoint`为`https://example.org` 187 | 188 | 2. 如果`endpoint`的域名CNAME记录为阿里云CDN, 在CDN为加速范围为全球时有遇到过如下报错`The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint.`, 则`endpoint`不能使用自定义域名, 使用OSS源站的endpoint. 189 | 190 | ### 配置了environment secret怎么不生效 191 | 192 | ![2021-05-21-16-47-59](https://image.fangbinwei.cn/github/aliyun-oss-website-action/2021-05-21-16-47-59_affec2b0.png) 193 | 194 | 如果使用environment secret, 那么需要如下类似的配置 195 | 196 | ```diff 197 | 198 | jobs: 199 | build: 200 | runs-on: ubuntu-latest 201 | + environment: your-environment-name 202 | 203 | ``` -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "aliyun-oss-website-action/config" 5 | "aliyun-oss-website-action/operation" 6 | "aliyun-oss-website-action/utils" 7 | "fmt" 8 | "os" 9 | "testing" 10 | 11 | "github.com/fangbinwei/aliyun-oss-go-sdk/oss" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func testSetStaticWebsiteConfig(t *testing.T) { 16 | assert := assert.New(t) 17 | 18 | err := operation.SetStaticWebsiteConfig() 19 | assert.NoError(err) 20 | } 21 | 22 | func testUpload(t *testing.T) { 23 | assert := assert.New(t) 24 | fmt.Println("---- [delete] ---->") 25 | errs := operation.DeleteObjects(config.Bucket) 26 | fmt.Println("<---- [delete end] ----") 27 | assert.Equal(len(errs), 0) 28 | 29 | lor, err := config.Bucket.ListObjects() 30 | assert.NoError(err) 31 | assert.Equal(0, len(lor.Objects)) 32 | 33 | records := utils.WalkDir(config.Folder) 34 | 35 | // overwrite, since dotenv doesn't support multiline 36 | config.Exclude = []string{"exclude.txt", "exclude/"} 37 | fmt.Println("---- [upload] ---->") 38 | uploaded, uploadErrs := operation.UploadObjects(config.Folder, config.Bucket, records, nil) 39 | fmt.Println("<---- [upload end] ----") 40 | assert.Equal(0, len(uploadErrs), uploadErrs) 41 | 42 | lor, err = config.Bucket.ListObjects() 43 | assert.NoError(err) 44 | assert.Equal(len(uploaded), len(lor.Objects)) 45 | 46 | // test exclude 47 | lor, err = config.Bucket.ListObjects(oss.Prefix("exclude")) 48 | assert.NoError(err) 49 | assert.Empty(lor.Objects) 50 | 51 | // test cache-control 52 | for _, u := range uploaded { 53 | // 如果自定义域名解析到了cdn, 这个接口会报错, 但是上面的测试流程正常 54 | // 避开方法: env中endpoint使用bucket的endpoint或者bucket域名, 而不是自定义域名 55 | props, err := config.Bucket.GetObjectDetailedMeta(u.ObjectKey) 56 | assert.NoError(err) 57 | cacheControl := props.Get("Cache-Control") 58 | if utils.IsImage(u.ObjectKey) { 59 | assert.Equal(config.ImageCacheControl, cacheControl) 60 | } else if utils.IsHTML(u.ObjectKey) { 61 | assert.Equal(config.HTMLCacheControl, cacheControl) 62 | } else { 63 | assert.Equal(config.OtherCacheControl, cacheControl) 64 | } 65 | } 66 | 67 | } 68 | 69 | func testUploadIncrementalFirst(t *testing.T) { 70 | assert := assert.New(t) 71 | 72 | fmt.Println("---- [incremental] ---->") 73 | incremental, err := operation.GetRemoteIncrementalConfig(config.Bucket) 74 | assert.Error(err) 75 | assert.Empty(incremental) 76 | fmt.Println("<---- [incremental end] ----") 77 | 78 | if incremental == nil { 79 | fmt.Println("---- [delete] ---->") 80 | errs := operation.DeleteObjects(config.Bucket) 81 | fmt.Println("<---- [delete end] ----") 82 | assert.Equal(len(errs), 0) 83 | } 84 | 85 | lor, err := config.Bucket.ListObjects() 86 | assert.NoError(err) 87 | assert.Equal(0, len(lor.Objects)) 88 | 89 | records := utils.WalkDir(config.Folder) 90 | 91 | // overwrite, since dotenv doesn't support multiline 92 | config.Exclude = []string{"exclude.txt", "exclude/"} 93 | fmt.Println("---- [upload] ---->") 94 | uploaded, uploadErrs := operation.UploadObjects(config.Folder, config.Bucket, records, incremental) 95 | fmt.Println("<---- [upload end] ----") 96 | assert.Equal(0, len(uploadErrs), uploadErrs) 97 | 98 | lor, err = config.Bucket.ListObjects() 99 | assert.NoError(err) 100 | assert.Equal(len(uploaded), len(lor.Objects)) 101 | 102 | fmt.Println("---- [incremental] ---->") 103 | err = operation.UploadIncrementalConfig(config.Bucket, uploaded) 104 | fmt.Println("<---- [incremental end] ----") 105 | assert.NoError(err) 106 | 107 | incremental, err = operation.GetRemoteIncrementalConfig(config.Bucket) 108 | assert.NoError(err) 109 | assert.Equal(len(uploaded), len(incremental.M)) 110 | for _, v := range uploaded { 111 | assert.False(v.Incremental) 112 | assert.Equal(v.ContentMD5, incremental.M[v.ObjectKey].ContentMD5) 113 | assert.Equal(v.CacheControl, incremental.M[v.ObjectKey].CacheControl) 114 | } 115 | 116 | // test exclude 117 | lor, err = config.Bucket.ListObjects(oss.Prefix("exclude")) 118 | assert.NoError(err) 119 | assert.Empty(lor.Objects) 120 | 121 | // test cache-control 122 | for _, u := range uploaded { 123 | // 如果自定义域名解析到了cdn, 这个接口会报错, 但是上面的测试流程正常 124 | // 避开方法: env中endpoint使用bucket的endpoint或者bucket域名, 而不是自定义域名 125 | props, err := config.Bucket.GetObjectDetailedMeta(u.ObjectKey) 126 | assert.NoError(err) 127 | cacheControl := props.Get("Cache-Control") 128 | if utils.IsImage(u.ObjectKey) { 129 | assert.Equal(config.ImageCacheControl, cacheControl) 130 | } else if utils.IsPDF(u.ObjectKey) { 131 | assert.Equal(config.PDFCacheControl, cacheControl) 132 | } else if utils.IsHTML(u.ObjectKey) { 133 | assert.Equal(config.HTMLCacheControl, cacheControl) 134 | } else { 135 | assert.Equal(config.OtherCacheControl, cacheControl) 136 | } 137 | } 138 | } 139 | 140 | func testUploadIncrementalSecond(t *testing.T) { 141 | assert := assert.New(t) 142 | 143 | fmt.Println("---- [incremental] ---->") 144 | incremental, err := operation.GetRemoteIncrementalConfig(config.Bucket) 145 | assert.NoError(err) 146 | fmt.Println("<---- [incremental end] ----") 147 | 148 | lor, err := config.Bucket.ListObjects() 149 | assert.NoError(err) 150 | assert.Equal(len(lor.Objects)-1, len(incremental.M)) 151 | 152 | records := utils.WalkDir(config.Folder) 153 | 154 | fmt.Println("---- [upload] ---->") 155 | uploaded, uploadErrs := operation.UploadObjects(config.Folder, config.Bucket, records, incremental) 156 | fmt.Println("<---- [upload end] ----") 157 | assert.Equal(0, len(uploadErrs), uploadErrs) 158 | 159 | lor, err = config.Bucket.ListObjects() 160 | 161 | assert.NoError(err) 162 | assert.Equal(len(uploaded), len(lor.Objects)-1) 163 | 164 | // incremental.M中剩余的项是待删除的, 数量为0, 因为此次上传和上次上传的文件一模一样 165 | assert.Equal(0, len(incremental.M)) 166 | 167 | fmt.Println("---- [delete] ---->") 168 | // 只删除.actioninfo 169 | errs := operation.DeleteObjectsIncremental(config.Bucket, incremental) 170 | fmt.Println("<---- [delete end] ----") 171 | assert.Equal(0, len(errs)) 172 | 173 | lor, err = config.Bucket.ListObjects() 174 | 175 | assert.NoError(err) 176 | assert.Equal(len(uploaded), len(lor.Objects)) 177 | 178 | fmt.Println("---- [incremental] ---->") 179 | err = operation.UploadIncrementalConfig(config.Bucket, uploaded) 180 | fmt.Println("<---- [incremental end] ----") 181 | assert.NoError(err) 182 | 183 | incremental, err = operation.GetRemoteIncrementalConfig(config.Bucket) 184 | assert.NoError(err) 185 | assert.Equal(len(uploaded), len(incremental.M)) 186 | for _, v := range uploaded { 187 | // 全都不需要上传, 命中incremental 188 | assert.True(v.Incremental) 189 | assert.Equal(v.ContentMD5, incremental.M[v.ObjectKey].ContentMD5) 190 | assert.Equal(v.CacheControl, incremental.M[v.ObjectKey].CacheControl) 191 | } 192 | 193 | } 194 | 195 | func testUploadIncrementalThird(t *testing.T) { 196 | assert := assert.New(t) 197 | folder := "testdata/group2" 198 | // 改变cache-control会让对应文件重新上传, 即使hash没变 199 | config.ImageCacheControl = "no-cache" 200 | 201 | fmt.Println("---- [incremental] ---->") 202 | incremental, err := operation.GetRemoteIncrementalConfig(config.Bucket) 203 | assert.NoError(err) 204 | fmt.Println("<---- [incremental end] ----") 205 | 206 | lor, err := config.Bucket.ListObjects() 207 | assert.NoError(err) 208 | assert.Equal(len(lor.Objects)-1, len(incremental.M)) 209 | 210 | records := utils.WalkDir(folder) 211 | 212 | fmt.Println("---- [upload] ---->") 213 | uploaded, uploadErrs := operation.UploadObjects(folder, config.Bucket, records, incremental) 214 | fmt.Println("<---- [upload end] ----") 215 | assert.Equal(0, len(uploadErrs), uploadErrs) 216 | 217 | // incremental.M中剩余的项是待删除的, 大于0 218 | assert.Greater(len(incremental.M), 0) 219 | for _, v := range uploaded { 220 | if v.ObjectKey == "empty.js" { 221 | assert.True(v.Incremental) 222 | continue 223 | } 224 | if v.ObjectKey == "example.js" { 225 | assert.False(v.Incremental) 226 | continue 227 | } 228 | if v.ObjectKey == "favicon.ico" { 229 | assert.False(v.Incremental) 230 | continue 231 | } 232 | t.Fail() 233 | } 234 | 235 | fmt.Println("---- [delete] ---->") 236 | errs := operation.DeleteObjectsIncremental(config.Bucket, incremental) 237 | fmt.Println("<---- [delete end] ----") 238 | assert.Equal(0, len(errs)) 239 | 240 | lor, err = config.Bucket.ListObjects() 241 | assert.NoError(err) 242 | assert.Equal(len(uploaded), len(lor.Objects)) 243 | 244 | fmt.Println("---- [incremental] ---->") 245 | err = operation.UploadIncrementalConfig(config.Bucket, uploaded) 246 | fmt.Println("<---- [incremental end] ----") 247 | assert.NoError(err) 248 | 249 | incremental, err = operation.GetRemoteIncrementalConfig(config.Bucket) 250 | assert.NoError(err) 251 | assert.Equal(len(uploaded), len(incremental.M)) 252 | for _, v := range uploaded { 253 | assert.Equal(v.ContentMD5, incremental.M[v.ObjectKey].ContentMD5) 254 | assert.Equal(v.CacheControl, incremental.M[v.ObjectKey].CacheControl) 255 | } 256 | 257 | } 258 | 259 | func TestAction(t *testing.T) { 260 | t.Run("SetStaticWebsiteConfig", testSetStaticWebsiteConfig) 261 | t.Run("First upload", testUpload) 262 | t.Run("Second upload", testUpload) 263 | t.Run("First incremental upload without .actioninfo", testUploadIncrementalFirst) 264 | t.Run("Second incremental upload", testUploadIncrementalSecond) 265 | t.Run("Third incremental upload, change cache-control", testUploadIncrementalThird) 266 | } 267 | 268 | func TestMain(m *testing.M) { 269 | code := m.Run() 270 | 271 | fmt.Println("Empty bucket after test") 272 | operation.DeleteObjects(config.Bucket) 273 | 274 | os.Exit(code) 275 | } 276 | --------------------------------------------------------------------------------